Verdict
No
Do not write tests that repeat a rule your type checker or linter enforces on every merge, such as argument types or a missing null check; spend the tests on the values the code computes.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityimmediately
- Reversibilitytrivial
- Test costtrivial
Do not write tests that repeat a rule your type checker or linter enforces on every merge. For the typical case, tsc, mypy or ESLint running in CI as a blocking step, Blast radius is users, Change frequency is regularly, Detectability is immediately, Reversibility is trivial, and Test cost is trivial. Detectability is immediately because a wrong argument type or an unchecked null fails the author's build, and Reversibility is trivial because code that never merges stores nothing. Rule R8 gives Do not test; once the check stops blocking the merge, Detectability moves to same-day and the decision becomes Test.
| When | Decision | Why |
|---|---|---|
| The code gives the type checker the shape of a JSON response through a cast, such as `as Profile` | Test: parse the response against a schema, and test that the loader rejects a renamed field | Detectability moves to eventually and Reversibility to with-effort: the checker trusts the cast, and users save records with the missing field |
| The rule does not block the merge: it is a dashboard warning, strict mode is off, or the line carries `eslint-disable` or `# type: ignore` | Test the paths the rule would flag, such as a missing value, until the rule blocks the merge | Detectability moves to same-day: a null dereference reaches users as a crash |
| The checked function takes an amount, and the type checker sees cents and euros as the same `number` | Test mandatory: one test with a known amount for each unit conversion | Blast radius rises to money, Detectability moves to eventually and Reversibility to costly: an amount in the wrong unit passes every type check |
| The checked code filters records by the user's account, and a security scanner such as CodeQL covers the file | Test mandatory: one allowed and one denied request from a user of another account | Blast radius rises to safety-or-legal and Detectability moves to never: the scanner matches injection patterns, not a filter on the wrong column |
| The team wrote its own lint rule, such as a ban on `fetch` outside the API client | Test the rule with valid and invalid code samples in ESLint's RuleTester | Blast radius falls to internal, Detectability moves to eventually and Reversibility to with-effort: a broken rule stops reporting silently |
What breaks if you don't test
In the typical case nothing breaks that CI misses: a call with the wrong type never reaches the main branch. Failures come from code the checker only appears to cover. A cast such as as Profile tells TypeScript to trust a JSON response, so a renamed API field arrives as undefined while the build stays green. An eslint-disable comment switches a rule off for one line that nobody rereads.
What you lose if you over-test
A test that calls getUser("42") under @ts-expect-error to prove that a string ID is rejected fails only when someone changes the signature on purpose, and then each change touches two files. These tests also inflate coverage: the lines run, the report goes green, and the calculation inside the function has no assertion on its result.
What to do instead
Make the check a gate: run tsc --noEmit or mypy --strict in CI, and ESLint with --max-warnings 0 so that a warning fails the build. Where input enters the program (JSON responses, environment variables, message payloads), replace casts with a schema parse and test that it rejects malformed input; the TypeScript handbook states that a type assertion has no runtime check. Spend unit tests on computed values and branches, which no checker sees.
When the answer changes
- The code gives the checker a type through a cast,
any, or a suppression comment. - The rule is a warning that does not fail CI, or strict mode is off for the file.
- The value carries money, dates, or permissions, which a type cannot validate.
Code example + Counterexample
The cast that unsubscribed users
This loader compiles under strict and passes ESLint:
type Profile = { id: string; weeklyDigest: boolean };
async function loadProfile(id: string): Promise<Profile> {
const res = await fetch(`/api/profiles/${id}`);
return (await res.json()) as Profile; // tsc checks nothing here
}
test("loadProfile rejects a response without weeklyDigest", async () => {
vi.stubGlobal("fetch", async () =>
Response.json({ id: "u1", weekly_digest: true }),
);
await expect(loadProfile("u1")).rejects.toThrow();
});
Suppose the API renames weeklyDigest to weekly_digest. The settings form then shows the box unchecked, and every user who saves the form unsubscribes without knowing it. "The type checker passed, so no test" is wrong for loadProfile: Detectability is eventually, because nobody reports an email that stopped arriving, and Reversibility is with effort, because a script must restore the setting from the audit log, so the framework returns Test. The test above fails against the cast and passes once loadProfile returns ProfileSchema.parse(await res.json()).
Related questions
FAQ
- Should I add unit tests for things already checked by a static analysis tool?
No, a rule that your static analysis tool enforces on every merge needs no unit test that repeats it. Write one when a cast or a suppression comment switches the check off, or when the value carries money or access.
- Is testing with linters enough?
No, a linter checks the form of code and a type checker checks shapes; neither checks returned values. A date function returning the UTC day instead of the user's local day passes both. Write a test that asserts the returned day.
- Should the no-magic-numbers lint rule apply to test code?
No, turn off no-magic-numbers for test files, because a literal expected value such as
1999keeps an assertion independent of the code under test. A test that imports the same constant as the code it checks still passes when that constant is wrong.- Do I need unit tests if I use TypeScript?
Yes, TypeScript replaces only the tests that check types; it does not check values, and a type assertion has no check at run time. Keep unit tests for calculations, branches and the parsing of external input.