Skip to content

Testing

The tech.ostack:kform-test module provides a set of utility functions for testing the validations of your form.

Testing validations is typically done in one of two ways:

  • Using a FormValidator to validate a whole form and check for the presence (or absence) of located validation issues.
  • Running individual validations via runValidation and checking for the presence (or absence) of non-located validation issues.

For this purpose, the KForm testing module provides the following assertion functions, which can be used for matching both ValidationIssues and LocatedValidationIssues:

To these functions, the concept of “matching” an issue means that the expected issue is a subset of the actual issue. For example, if the actual issue (one obtained via FormValidator.validate) is:

LocatedValidationError(
    path = "/returnDate",
    code = "returnDateBeforeDeparture",
    dependencies = setOf("/departureDate"),
    data = mapOf("minDate" to "2026-01-01"),
)

Then it is considered a match for the expected issue:

LocatedValidationError(path = "/returnDate", code = "returnDateBeforeDeparture")

I.e. when testing, it is sufficient to provide only the information of the issue that we are interested in checking, without having to provide its full details.

Running tests against the whole form

To test a validation over a whole form, start by constructing a FormValidator and call validate on it with the mock form instance to test. Use one of the assertion functions to confirm that an issue was (or wasn’t) found.

The following example tests that the email field of the bus trip form introduced in previous sections is required. Note how, because our model defines default values for all fields, we can tailor our mock value to the validation being tested by only overriding relevant fields:

Testing that the email field is required
val busTripFormValidator = FormValidator(BusTripFormSchema)

@Test
fun testEmailRequired() = runTest {
    assertContainsMatchingIssue(
        LocatedValidationError("/email", "valueMissing"),
        busTripFormValidator.validate(BusTripForm(), "/email"),
    )
    assertNotContainsMatchingIssue(
        LocatedValidationError("/email", "valueMissing"),
        busTripFormValidator.validate(
            BusTripForm(email = "enorton@test.com"),
            "/email",
        ),
    )
}

Custom testing utilities

To simplify writing tests such as the above, you might want to define custom utility functions tailored to your form, such as:

Custom utilities for testing validations
val busTripFormValidator by lazy { FormValidator(BusTripFormSchema) }

fun validateBusTripForm(
    busTripForm: BusTripForm,
    path: String,
    externalContexts: ExternalContexts = emptyMap(),
) = busTripFormValidator.validate(busTripForm, path, externalContexts)

suspend fun assertBusTripFormContainsError(
    path: String,
    code: String,
    busTripForm: BusTripForm,
    externalContexts: ExternalContexts = emptyMap(),
) =
    assertContainsMatchingIssue(
        LocatedValidationError(path, code),
        validateBusTripForm(busTripForm, path, externalContexts),
    )

suspend fun assertBusTripFormNotContainsError(
    path: String,
    code: String,
    busTripForm: BusTripForm,
    externalContexts: ExternalContexts = emptyMap(),
) =
    assertNotContainsMatchingIssue(
        LocatedValidationError(path, code),
        validateBusTripForm(busTripForm, path, externalContexts),
    )

suspend fun assertBusTripFormContainsWarning(
    path: String,
    code: String,
    busTripForm: BusTripForm,
    externalContexts: ExternalContexts = emptyMap(),
) =
    assertContainsMatchingIssue(
        LocatedValidationWarning(path, code),
        validateBusTripForm(busTripForm, path, externalContexts),
    )

suspend fun assertBusTripFormNotContainsWarning(
    path: String,
    code: String,
    busTripForm: BusTripForm,
    externalContexts: ExternalContexts = emptyMap(),
) =
    assertNotContainsMatchingIssue(
        LocatedValidationWarning(path, code),
        validateBusTripForm(busTripForm, path, externalContexts),
    )

Note

The usage of lazy in the construcion of the validator is simply a way of making errors that occur during the construction of the schema more obvious, by deferring them until a test runs.

With these utilities, the previous tests can be written as:

Testing that the email field is required (using custom utilities)
@Test
fun testEmailRequired() = runTest {
    assertBusTripFormContainsError("/email", "valueMissing", BusTripForm())
    assertBusTripFormNotContainsError(
        "/email",
        "valueMissing",
        BusTripForm(email = "enorton@test.com"),
    )
}

Testing individual validations

To test validations individually, the testing module provides the runValidation function. Use it together with the assertion functions to check for the presence (or absence) of validation issues.

As an example, let us look at a validation introduced in the previous section to check that a trip’s return date is not before the departure date:

Validation that ensures that the return date isn't before the departure date
object ValidReturnDate : Validation<LocalDate>() {
    private val ValidationContext.departureDate: LocalDate? by dependency("/departureDate")

    override fun ValidationContext.validate() = flow {
        if (departureDate != null && value < departureDate!!) {
            emit(ValidationError("returnDateBeforeDeparture"))
        }
    }
}

It can be tested as follows:

Testing the “valid return date” validation
@Test
fun testValidReturnDate() = runTest {
    assertContainsMatchingIssue(
        ValidationError("returnDateBeforeDeparture"),
        runValidation(
            formSchema = BusTripFormSchema,
            validation = ValidReturnDate,
            value = LocalDate(2026, 1, 1),
            path = "/returnDate",
            dependencyValues = mapOf("departureDate" to LocalDate(2026, 2, 1)),
        ),
    )
    assertNotContainsMatchingIssue(
        ValidationError("returnDateBeforeDeparture"),
        runValidation(
            formSchema = BusTripFormSchema,
            validation = ValidReturnDate,
            value = LocalDate(2026, 2, 1),
            path = "/returnDate",
            dependencyValues = mapOf("departureDate" to LocalDate(2026, 1, 1)),
        ),
    )
}

As can be seen, the context under which the validation runs must be provided to the runValidation function: this includes the schema of the whole form, the path to the field where the validation is located, the value being validated, and any dependencies required by the validation (which may include external values).

Testing validations from Java

The assertion functions are available from Java under the TestUtils class. Due to naming conflicts, they have different names depending on whether ValidationIssues or LocatedValidationIssues are being matched:

  • TestUtils.assertMatchingIssues, TestUtils.assert(Not)ContainsMatchingIssues, and TestUtils.assert(Not)ContainsMatchingIssue: for matching ValidationIssues.
  • TestUtils.assertMatchingLocatedIssues, TestUtils.assert(Not)ContainsMatchingLocatedIssues, and TestUtils.assert(Not)ContainsMatchingLocatedIssue: for matching LocatedValidationIssues.

A Java rewrite of the previous email field testing would look like the following:

Testing that the email field is required (Java)
AsyncFormValidator<BusTripForm> busTripFormValidator =
        new AsyncFormValidator<>(getBusTripFormSchema());

@Test
void testEmailRequired() {
    TestUtils.assertContainsMatchingLocatedIssue(
            new LocatedValidationError("/email", "valueMissing"),
            busTripFormValidator.validate(new BusTripForm(), "/email").join());
    TestUtils.assertNotContainsMatchingLocatedIssue(
            new LocatedValidationError("/email", "valueMissing"),
            busTripFormValidator
                    .validate(new BusTripForm("enorton@test.com", Table.of()), "/email")
                    .join());
}

A Java-compatible runValidation function (equivalent, but with support for a custom Executor) is available under the AsyncTestUtils class. The previous “valid return date” testing can be rewritten as:

Testing the “valid return date” validation (Java)
@Test
void testValidReturnDate() {
    TestUtils.assertContainsMatchingIssue(
            new ValidationError("returnDateBeforeDeparture"),
            AsyncTestUtils.runValidation(
                            getBusTripFormSchema(),
                            new ValidReturnDate(),
                            new LocalDate(2026, 1, 1),
                            "/returnDate",
                            Map.of("departureDate", new LocalDate(2026, 2, 1)))
                    .join());
    TestUtils.assertNotContainsMatchingIssue(
            new ValidationError("returnDateBeforeDeparture"),
            AsyncTestUtils.runValidation(
                            getBusTripFormSchema(),
                            new ValidReturnDate(),
                            new LocalDate(2026, 2, 1),
                            "/returnDate",
                            Map.of("departureDate", new LocalDate(2026, 1, 1)))
                    .join());
}

Note

By default, both AsyncFormValidator and AsyncTestUtils run validations using the kotlinx.coroutines Default thread pool. This can be tweaked via the last (optional) argument of the AsyncFormValidator constructor and AsyncTestUtils.runValidation method, which both take an Executor.

If your validations should run on the current thread, e.g. in order to access thread-local context, you might want to provide Runnable::run as the executor instead:

AsyncFormValidator<BusTripForm> validator =
        new AsyncFormValidator<>(getBusTripFormSchema(), Runnable::run);
List<ValidationIssue> issues =
        AsyncTestUtils.runValidation(
                        getBusTripFormSchema(),
                        new ValidReturnDate(),
                        new LocalDate(2026, 1, 1),
                        "/returnDate",
                        Map.of("departureDate", new LocalDate(2026, 2, 1)),
                        Map.of(),
                        Runnable::run)
                .join();