skip to content

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