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
FormValidatorto validate a whole form and check for the presence (or absence) of located validation issues. - Running individual validations via
runValidationand 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:
assertMatchingIssues: asserts that the actual issues match the provided expected issues, ignoring the order.assertContainsMatchingIssues/assertNotContainsMatchingIssues: asserts that the actual issues contain, or not, the provided expected issues.assertContainsMatchingIssue/assertNotContainsMatchingIssue: asserts that the actual issues contain, or not, the provided expected issue.
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:
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:
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:
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:
@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:
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:
@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, andTestUtils.assert(Not)ContainsMatchingIssue: for matchingValidationIssues.TestUtils.assertMatchingLocatedIssues,TestUtils.assert(Not)ContainsMatchingLocatedIssues, andTestUtils.assert(Not)ContainsMatchingLocatedIssue: for matchingLocatedValidationIssues.
A Java rewrite of the previous email field testing would look like the following:
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:
@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: