Advanced topics Automated testing Console and GUI

Testing Klyn Applications

The Klyn test framework discovers packaged test classes and executes the complete selection in one isolated test process and one JIT session. It reports class and method results incrementally and can persist the complete execution as JSON. The same tests/Start.kn entry point supports console and graphical runs.

Minimal Test Starter

A project needs only one small entry point. Pass arguments unchanged to TestLauncher.run(); the launcher handles package filters, console reporting, JSON output, help, and graphical mode. Its return value is the process exit code.

# tests/Start.kn
import klyn.unittest.launcher

exit(TestLauncher.run(arguments))

Keeping option handling in the framework guarantees that every project exposes the same command line and that new framework options do not require changes to its starter.

One process for the complete selection

All selected classes share one test process and one JIT session. This avoids repeating runtime, compiler, and standard-library initialization for every class while retaining a distinct JIT namespace and a structured result for each source. A fatal native crash can stop the remaining classes; the runner reports every source that could not execute as an infrastructure failure.

Test Source Layout

The tests directory is the class-loader root. Every test package must therefore match its directory, exactly as it does for application sources. Discovery is driven by @TestClass, not by a filename convention; ending test filenames with Test.kn remains the recommended convention.

tests/
|-- Start.kn
`-- inventory/
    |-- InventoryServiceTest.kn
    `-- persistence/
        `-- InventoryRepositoryTest.kn
# tests/inventory/InventoryServiceTest.kn
package inventory

import klyn.unittest

@TestClass
public class InventoryServiceTest:

    @Test
    public testAvailableQuantity():
        assert 12 - 4 == 8
Packages are validated

Declaring package inventory.persistence in tests/inventory/InventoryServiceTest.kn is an error. Move the file or correct its package; the runner never hides a package-layout problem.

Test and Fixture Annotations

Annotation Execution point
@TestClass Marks the public class containing test methods.
@BeforeClass Runs once before the first test method.
@Before Runs before every test execution, including each data row.
@Test Marks one independently reported test method.
@Case / @Cases Supply positional arguments or rows to a @Test method; one-parameter rows may be single values.
@After Runs after every test execution, including each data row.
@AfterClass Runs once after the last test method.
package inventory

import klyn.unittest

@TestClass
public class InventoryRepositoryTest:

    private _quantity as Int

    @BeforeClass
    public static prepareDatabase():
        print("Preparing test data")

    @Before
    public resetQuantity():
        this._quantity = 10

    @Test
    public testRemoval():
        this._quantity -= 3
        assert this._quantity == 7

    @After
    public verifyInvariant():
        assert this._quantity >= 0

    @AfterClass
    public static closeDatabase():
        print("Releasing test data")

Data-Driven Tests

Keep the assertions in one typed method and declare its inputs with repeated @Case(...) annotations. Pass exactly one value per parameter directly, in parameter order. Use @Cases([(...), (...)]) for a compact table. Both annotations belong to klyn.unittest and accompany @Test inside a @TestClass.

package examples

import klyn.unittest

@TestClass
public class ArithmeticTest:

    @Test
    @Case("demo", 2, "demo-2")
    @Case("klyn", 10, "klyn-10")
    public labels(prefix as String, value as Int, expected as String):
        assert f"{prefix}-{value}" == expected

    @Test
    @Cases([(1, 1), (2, 8), (3, 27), (4, 64)])
    public cubes(value as Int, expected as Int):
        assert value ** 3 == expected

    @Test
    @Cases(["Klyn", "Compiler", "Runtime"])
    public nonEmptyName(value as String):
        assert value.size > 0ul

    @Test(expected=ValueException)
    @Case(-1)
    @Case(-10)
    public rejectsNegative(value as Int):
        if value < 0:
            throw ValueException("Negative value")

@Case accepts a variadic number of positional values: @Case(42) supplies one argument and @Case() supplies none. A tuple or collection is a single argument: @Case((1, 2)) passes one tuple, not two integers. With @Cases, each explicit tuple is an argument row. For a method with exactly one parameter, non-tuple values are accepted directly: @Cases([42, 43]) is equivalent to @Cases([(42,), (43,)]), and both forms may be mixed. To pass a tuple as the single argument, wrap it in a one-element row: @Cases([((1, 2),), ((3, 4),)]). Methods with zero or multiple parameters still require tuple rows. An empty @Cases([]) is rejected, so a missing dataset cannot silently turn a test green. The compiler rejects incorrect argument counts and incompatible types at their source locations. Test methods must return Void, be non-generic and use fixed, by-value parameters, not ref or out parameters or variadic packs. Even optional parameters require a value in each row.

Rows run in declaration order, including when @Case and @Cases are combined. Duplicate rows are preserved. Argument expressions are evaluated afresh for each execution, after @Before, not once during discovery. The argument pack and the table wrappers describe the cases; they are not materialized at runtime. A tuple or collection supplied as an actual method argument is created normally.

Each row is a separately counted result named cubes[1], cubes[2], and so on, in the console, JSON reports and GUI. A failure does not skip the next row. @Before and @After surround each execution; the test instance is shared within the class, as for ordinary tests. @BeforeClass and @AfterClass still run once. The @Test timeout and expected exception apply independently to every row. The class timeout covers the whole table.

Expected Errors and Timeouts

Use expected when an exception is the successful outcome of a test. A @Test(timeout=...) budget limits one test method, while @TestClass(timeout=...) limits the complete lifecycle of a test class. Both values are expressed in milliseconds; zero, the default, disables the corresponding limit.

@Test(expected=ValueException)
public testRejectsNegativeQuantity():
    throw ValueException("Quantity cannot be negative")

@Test(timeout=500)
public testCompletesQuickly():
    value = 40 + 2
    assert value == 42

@TestClass(timeout=2000)
public class FastContractTest:

    @Test
    public completesWithinTheClassBudget():
        assert true

A timed-out method is interrupted and reported as failed. It cannot satisfy an expected declaration. A class budget includes construction, @BeforeClass, @Before, every @Test, @After and @AfterClass; source compilation is intentionally excluded. When method and class budgets are both present, the first deadline reached stops execution.

Asserting Exceptions

Use Assert.assertThrows<ExceptionType> with a function or lambda without parameters to check one operation without ending the test method. The callable is invoked once and its return value, if any, is ignored. Assert.assertFails accepts the same kind of callable and checks for any exception. Passing an evaluated expression such as Assert.assertThrows(compile(source)) produces a compile-time TypeError: wrap the operation in lambda(): compile(source) (or lambda: compile(source)). A string containing source code is just a string, not an implicit call to the compiler. These helpers are ordinary static Klyn methods; their names do not alter argument evaluation, type checking, or exception handling.

import klyn.unittest

invalidScalar = -1
Assert.assertThrows<ValueException>(lambda (): Char(invalidScalar))

value as Int = 42
Assert.assertThrows<TypeError>(lambda (): eval("value = 3.14"))
assert value == 42
def failWithoutArguments() as Void:
    throw ValueException("Invalid value")

Assert.assertThrows<ValueException>(failWithoutArguments)

The first assertion checks a runtime value error. The second explicitly compiles code with eval and checks that assigning a Double to the visible Int variable is rejected. A failed compilation does not replace the variable's value or static type.

compile(source) checks a separate source fragment without executing it. Imports visible at the call site, such as import klyn.unittest, also apply inside that fragment. Local variables are not captured, and declarations or imports introduced in the fragment do not leak into the caller. Use eval when execution in the caller's visible context is required.

Running Tests

# Run every @TestClass below tests
klyn tests/Start.kn

# Run one package and all of its subpackages
klyn tests/Start.kn klyn.data

# Combine any number of package roots
klyn tests/Start.kn klyn.data klyn.databases

# Display every supported option
klyn tests/Start.kn --help

Selected sources run sequentially in one test process. Compilation failures are reported for the affected class; an unrecoverable native crash can prevent the remaining classes from running. Tests must restore shared state and release resources in their fixture cleanup. Successful entries and the final successful summary are green; failures are red and identify the affected test methods. Each class occupies one console line; OK or KO is aligned against the right edge, and long source names are shortened in the middle without hiding the test filename.

Running /project/tests/klyn/regex/RegExTest.kn...               0.042 s OK
Test summary: 1/1 test class(es) passed; 8/8 test method(s) passed; 0.042 s.

JSON Reports

# Default destination: test-results.json
klyn tests/Start.kn --json

# Explicit destination
klyn tests/Start.kn --json=reports/collections.json klyn.collections

The report records duration, source, package, class and method status, captured standard output and error streams, stack traces, and infrastructure failures. It can be loaded later by the graphical runner.

Graphical Test Runner

klyn tests/Start.kn --gui
klyn tests/Start.kn --gui klyn.io klyn.regex

The GUI uses DockWorkspace. Its tree groups packages, classes, and methods and displays a red or green status dot at the right of each row. The root stays expanded; passing branches start collapsed, while every branch containing a failure starts expanded. Selecting a package or class aggregates its details in the central area. The window opens before test execution starts; tests run in an isolated worker and completed classes are published to the tree, output, and summary in real time without blocking GUI painting or input.

  • Output preserves the complete nominal and error streams.
  • Details focuses on the selected package, class, or method.
  • File > Save report persists the current report as JSON.
  • File > Load report opens a previous JSON execution.

Save and load actions use the embedded SaveFileLightBox and OpenFileLightBox from klyn.gui.windows.dialogs. They remain inside the test-runner window and do not start a nested event loop.

One entry point

Graphical execution is not a separate program. Always use klyn tests/Start.kn --gui, so discovery, filtering, isolation, and result semantics remain identical to console mode.

IDE Integration

The Klyn extension uses the native VS Code Testing API. Starting a test from the editor, gutter, or Explorer reveals the Tests view and result panel immediately. Packages, classes, and methods remain hierarchical; selecting any node displays the output, duration, and errors aggregated for that node.

Internally, --json-stream publishes framed JSON Lines events whenever a class starts or completes. This lets IDEs update their test tree in real time while the regular --json report remains the authoritative final snapshot and fallback.

Programmatic Execution

Build tools can use TestRunner and TestReport directly. Package filters retain the same exact-or-descendant semantics as the command line.

import klyn.io
import klyn.unittest

runner = TestRunner(FolderPath("tests"))
runner.consoleOutput = false

report = runner.run(["klyn.collections"])
report.save(FilePath("collection-results.json"))

if not report.passed:
    Application.exit(Application.EXIT_FAILURE)