skip to content

Tags & Conditional Execution

Tags plus boolean tag expressions select which specs run in a given invocation, and per-test config or prefixes can disable tests conditionally. Interviewers ask how you'd split fast unit runs from slow integration runs in CI — this is Kotest's answer.

on this pageshow

explore

questions

4

Explain Kotest's tagging system: how you attach a Kotest Tag to a spec or to an individual test, and how the kotest.tags system property expression decides which tests run — including what happens to tests that carry no tags at all.

level: middleimportance: must knowfreq 45%

answer

  1. object Slow : Tag(); NamedTag for dynamic
  2. @Tags on spec, config(tags = ...) on test
  3. -Dkotest.tags with ! & | and parentheses
  4. exclusion-only = untagged still run
  5. any inclusion = untagged excluded

basics

~20 s

You define a Tag (an object extending Kotest's Tag, or NamedTag("Slow")), attach it with the @Tags annotation on a spec or config(tags = setOf(...)) on a test, then filter with -Dkotest.tags using an expression like "Linux & !Slow". Pure exclusions keep untagged tests; adding an inclusion means only tests carrying that tag run.

solid answer

~50 s

A Kotest tag is a value, not a string scattered around: `object Slow : Tag()` (the tag name defaults to the class's simple name), or `NamedTag("Slow")` when you need it dynamically. You attach it with the `@Tags("Slow")` annotation on a spec class, or per test via `test("x").config(tags = setOf(Slow)) { }`. Tags on a spec are inherited by all its tests, and a tag on a container is inherited by its nested tests. Filtering happens with the `kotest.tags` system property, which takes a boolean expression over tag names: `!`, `&`, `|` and parentheses — for example `-Dkotest.tags="Linux & !Slow"`. The rule people get wrong is untagged tests. If the expression contains only exclusions, everything runs except what carries an excluded tag. As soon as the expression names a tag to include, only tests carrying that tag run — untagged tests are excluded too. So `!Slow` on a suite where nothing is tagged runs everything; `Integration` runs nothing until you actually tag the integration tests.

code

kotlin · 14 lines
kotlin
object Slow : Tag()
object Docker : Tag()

@Tags("Docker")
class RepositorySpec : FunSpec({

   // inherits Docker from the spec
   test("loads an order") { }

   // effective tags: Docker + Slow
   test("reindexes everything").config(tags = setOf(Slow)) { }
})

// run with: -Dkotest.tags="Docker & !Slow"

go deeper

for a junior

Recall that tags are objects attached with @Tags or config(tags = ...) and filtered with the kotest.tags system property expression.

for a middle

Be precise about the untagged rule in both directions and about tag inheritance from spec and container down to the test.

for a senior

Talk about designing the tag vocabulary around the jobs that filter on it, and diagnosing a zero-tests-ran job by checking tag names against the expression.

for a principal

Weigh tag-driven suite partitioning against splitting modules or source sets, and set the policy that every tag must correspond to a real job that skips it.

## Tags are typed values Kotest models a tag as an object: `object Slow : Tag()`. The tag's name defaults to the class's simple name, so `Slow` is matched by the literal `Slow` in an expression. When the tag must be computed at runtime, `NamedTag("Slow")` builds one from a string. Using objects rather than raw strings means a typo is a compile error at the attachment site, and find-usages tells you what is tagged. ## Attaching tags There are two attachment points that matter. - **Spec level, via annotation**: `@Tags("Slow", "Docker")` on the spec class. Every test in that spec inherits the tags. - **Test level, via config**: `test("reads from postgres").config(tags = setOf(Docker)) { ... }`. Tags on a container are inherited by tests nested inside it. Inheritance is additive: a test's effective tag set is its own tags plus everything it inherits from its enclosing containers and its spec. ## The filter expression At runtime you pass `-Dkotest.tags="<expression>"` as a JVM system property. The expression is boolean over tag names with `!` (not), `&` (and), `|` (or) and parentheses: - `Linux` — only tests tagged Linux - `!Slow` — everything except tests tagged Slow - `Linux & !Slow` — tests tagged Linux that are not tagged Slow - `(Linux | Mac) & !Docker` Kotest 4 used the separate `kotest.tags.include` / `kotest.tags.exclude` properties; the single expression property is the Kotest 5 surface, and it is the one to name unless you are discussing a legacy 4.x build. ## The untagged-test rule This is the part interviews probe. Evaluate the expression against the test's effective tag set: - **Exclusion-only expressions are permissive.** `!Slow` evaluates true for a test with no tags, so untagged tests run. This is the shape you want for "run everything except the expensive stuff". - **Any inclusion is restrictive.** `Integration` evaluates false for an untagged test, so untagged tests are excluded along with everything tagged differently. This is the shape for "run only the integration suite". The classic failure is a team adding `-Dkotest.tags=Integration` to a nightly job before anyone has tagged anything, watching zero tests run, and reporting it green. The inverse failure is assuming `!Slow` also excludes untagged tests and being surprised the fast job still takes ten minutes. ## Practical taxonomy Keep the tag vocabulary small and orthogonal: a capability axis (`Docker`, `Network`), a cost axis (`Slow`), maybe a platform axis. Tags that encode a team name or a feature area tend to rot, because nobody updates them when code moves. Every tag should answer "which job would want to skip this?" — if no job would, the tag has no reason to exist. Define tags in one shared file so the set is discoverable, and pair each with the job that uses it. A tag nobody filters on is dead weight that still costs review attention. ## Where tags sit in the config surface Tags decide *whether a test is selected for the run*. They are not the only conditional switch — test config carries `enabled`/`enabledIf` for per-test conditions, and there are spec-level annotations for disabling a whole class — but tags are the one driven from outside the code, by the person or job launching the run. That is what makes them the tool for environment-shaped suites: the decision is recorded in the job definition, not hidden in a runtime probe.

  • A CI job runs with -Dkotest.tags="Integration" and reports zero tests executed, yet the suite has hundreds of tests. What is the likely cause?
    The expression contains an inclusion, so only tests whose effective tag set contains Integration are selected — untagged tests are excluded. If nobody has attached the Integration tag, or it was attached under a different name than the expression uses, nothing matches. Check the tag object's name against the literal in the expression, and remember spec-level @Tags is what most integration specs should be using.
  • How do tags interact with nesting and inheritance?
    A test's effective tag set is the union of its own tags, those of every enclosing container, and those of the spec. So tagging a spec @Tags("Docker") is enough to exclude every test inside it, and tagging one container scopes the tag to that subtree. There is no way to subtract an inherited tag from a nested test, so structure specs so the broad tag really does apply to everything inside.

saying these in an interview costs you the question

  • Believing an exclusion-only expression like !Slow also drops untagged tests
  • Believing an inclusion expression still runs untagged tests
  • Naming Kotest 4's kotest.tags.include / kotest.tags.exclude as the current filtering surface
  • Assuming a tag on a test overrides or removes a tag inherited from its spec
  • Treating tag names as free-form strings and never noticing a mismatch between the tag and the expression

context

open as a page

Kotest lets you disable a test by using an x-prefixed builder (xtest, xcontext, xdescribe, xgiven), or by prefixing the test name with a bang (!), and lets you prefix a name with f: to focus it. Explain what each does, which tests they can be applied to, and which one wins when several are in play.

level: juniorimportance: should knowfreq 40%

basics

~20 s

x-builders and the ! bang prefix both register a test as disabled, so it is reported as skipped and a disabled container skips its whole subtree. f: focus runs only focused root tests in that spec. Bang beats focus, and both prefixes only work on root-level tests.

open as a page

Kotest's test config accepts enabled, enabledIf and enabledOrReasonIf. What is the difference between the three, and why can enabled = someRuntimeCheck() behave differently from enabledIf = { someRuntimeCheck() }?

level: middleimportance: should knowfreq 32%

basics

~20 s

enabled is a plain Boolean fixed when the test is registered; enabledIf is a lambda taking the TestCase and evaluated later, when the engine decides whether to run that test; enabledOrReasonIf returns Kotest's Enabled value so the skip carries a printed reason. A value computed for enabled is frozen at registration time; the lambda is not.

open as a page

A Kotest run uses a tag filter that should exclude a whole spec, yet the spec's constructor side effects — starting a container, opening a connection — still happen. Explain why a class-level Kotest @Tags annotation and tags declared from inside the spec body differ here, and what other spec-level switches Kotest offers (@Ignored, @EnabledIf with an EnabledCondition).

level: seniorimportance: should knowfreq 28%

basics

~30 s

A tag declared by the class-level @Tags annotation can be read from the class without constructing the spec, so an excluded spec is never instantiated. Tags declared inside the spec body are only discoverable after construction, so the constructor and its side effects run before the filter can apply. Kotest also offers @Ignored to disable a spec class outright and @EnabledIf with an EnabledCondition for a programmatic spec-level gate.

open as a page