Chapter 22 of 24 · TypeScript for Beginners — Complete Free Tutorial
Testing with TypeScript
Types and tests fail at different moments: types at save time, tests at run time. This twenty-second chapter of the 24-chapter TypeScript for Beginners series shows that the two are stronger together — how to type tests with Vitest or Jest, how to exercise typed functions and hooks, how to mock modules without losing their signatures, and how to test the types themselves with expectTypeOf and vitest --typecheck.
Tests are TypeScript files too
Test files are ordinary modules, so they are checked by the same tsc --noEmit run as your source. Vitest ships its own types; the recommended style imports them explicitly rather than relying on globals:
npm install --save-dev vitest
npm pkg set scripts.test="vitest run"
import { describe, expect, it } from 'vitest';
import { slugify } from './slug';
describe('slugify', () => {
it('lowercases and joins words', () => {
expect(slugify('TypeScript Rules')).toBe('typescript-rules');
});
});
Expected output: npx vitest run prints, at the end of the run:
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 12:04:31
Duration 107ms (transform 64%, import 24%, worker 7%, tests 5%)
If you prefer globals, add "types": ["vitest/globals"] to compilerOptions and set globals: true in the test config; for Jest the equivalent setup is a transform such as ts-jest plus @types/jest. Either way, the same tsconfig covers tests, which is exactly what makes the next sections useful.
Typing describe, it and expect
The describe and it callbacks take no parameters — everything you need is a closure, and any helper you define carries its own annotations. What the framework contributes is the type of the value you passed to expect:
- Numeric and structural matchers are typed.
expect(rows).toHaveLength('x')reportserror TS2345: Argument of type 'string' is not assignable to parameter of type 'number'., andtoBeGreaterThanaccepts onlynumber | bigint. - Equality matchers accept anything —
toBeandtoEqualare typed loosely on purpose, because they assert identity rather than a shape. Type safety for those comes from the function under test, or fromexpectTypeOfbelow. - Async assertions return promises, so
await expect(load()).rejects.toThrow(HttpError)is required, not optional; forgettingawaitproduces a passing test that never ran.
it('rejects when the server fails', async () => {
await expect(safeFetch('/missing')).rejects.toThrow('HTTP 404');
});
it('keeps ids unique', () => {
const ids = courses.map((course) => course.id);
expect(new Set(ids).size).toBe(ids.length);
});
Testing typed functions and hooks
A typed function deserves tests that check behaviour, not annotations — but the annotations make the tests pleasant to write, because autocomplete knows the shapes. Hooks need a renderer: @testing-library/react exports renderHook, which types result.current from whatever your callback returns.
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
import { expect, it } from 'vitest';
function useCounter(start: number) {
const [count, setCount] = useState(start);
return { count, increment: () => setCount((c) => c + 1) };
}
it('increments from the given start', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
act(() => result.current.increment());
expect(result.current.count).toBe(11);
});
Expected output: 1 test passed. The comment on line 1 selects the DOM environment; the alternative is test: { environment: 'jsdom' } in your Vitest config, with the jsdom package installed. Two things are typed for free here: result.current is { count: number; increment: () => void }, so a typo in a property name is a compile error, and increment() cannot be called with arguments that do not exist.
Testing the types themselves
Some guarantees are purely static: a function must accept exactly these arguments, a return type must stay narrowed, an invalid call must keep failing to compile. Vitest answers all three.
import { assertType, expectTypeOf, it } from 'vitest';
import { parseJson, slugify } from './lib';
import type { Result } from './result';
it('parseJson returns a Result of the requested type', () => {
expectTypeOf(parseJson<number>('42'))
.toEqualTypeOf<Result<number, ParseError>>();
expectTypeOf(slugify).parameter(0).toBeString();
expectTypeOf(slugify).returns.toBeString();
assertType<string>(slugify('x'));
});
Those assertions never execute — they are checked by the compiler. To run them as part of the suite, put them in *.test-d.ts files and enable the type-test runner:
npx vitest run --typecheck
Expected output:
Testing types with tsc and vue-tsc is an experimental feature.
Test Files 2 passed (2)
Tests 4 passed (4)
Type Errors no errors
Start at 12:04:42
Duration 824ms (typecheck 97%, transform 2%, tests 1%)
Declaration tests also pin down what must not compile, using // @ts-expect-error with a reason on the line above the invalid call. The directive is self-policing: if the next line ever stops erroring, the test fails with Unused '@ts-expect-error' directive., so a loosened API cannot slip past review. That self-policing is why the next chapter prefers it over @ts-ignore.
Mocking typed modules and table-driven cases
Mocking usually destroys types; the typed path keeps them. Declare the real signature in vi.fn<...>, hand the factory back to vi.mock, and narrow with vi.mocked:
import { expect, it, vi } from 'vitest';
import { loadCourse } from './load';
import { getCourse } from './api';
vi.mock('./api', () => ({
getCourse: vi.fn<(id: number) => Promise<Course>>(),
}));
const mockedGetCourse = vi.mocked(getCourse);
mockedGetCourse.mockResolvedValue({ id: 1, title: 'Types', minutes: 45 });
it('loads the mocked course', async () => {
const course = await loadCourse(1);
expect(course.title).toBe('Types');
expect(mockedGetCourse).toHaveBeenCalledWith(1);
});
Because the mock carries (id: number) => Promise<Course>, calling it with a string is a compile error even though the implementation is fake. Jest offers the same shape through jest.fn<...> and jest.mocked — the pattern transfers unchanged.
Repeated cases belong in a typed table instead of copy-pasted tests. A tuple array keeps each row honest about its shape, and the callback parameters are inferred from it:
const cases: Array<[string, string]> = [
['TypeScript Rules', 'typescript-rules'],
[' spaced out ', 'spaced-out'],
];
it.each(cases)('slugify(%s) -> %s', (input, expected) => {
expect(slugify(input)).toBe(expected);
});
Expected output: two separately named tests in the report, one per row — a failing row names its own input, so the console tells you which case broke without opening the file.
Common mistakes
- Leaving test files outside the type-check — a
tsconfigwhoseincludecovers onlysrcmeans broken tests compile forever; addsrc/**/*.test.tsor a dedicated config. - Trusting a matcher that accepts anything —
toBewill not object toexpect(count).toBe('10'); assert withexpectTypeOfwhen the type is the contract. - Forgetting
awaiton async assertions — the test passes immediately and the real failure surfaces later, in an unrelated test. - Casting the mock instead of narrowing it —
getCourse as neverdiscards every parameter type; usevi.mockedand keep the signature. - Writing
@ts-expect-errorwithout a reason — an unexplained directive rots; state what should fail and why, or the next reader cannot tell whether it is still needed.
Practice checklist
- Install Vitest, write one typed test for a function from an earlier chapter, and confirm the summary shows 1 passed.
- Deliberately pass a string to
toHaveLengthand read the TS2345 error before fixing it. - Convert a set of five similar assertions into one typed
it.eachtable. - Add a
*.test-d.tsfile with oneexpectTypeOfassertion and runnpx vitest run --typecheck. - Mock one module with
vi.fn<...>, then pass a wrong argument type to prove the mock still checks call sites.
What's next?
Your types are now covered by tests too. Next comes the situation most teams actually start from — a working JavaScript codebase — in Migrating JavaScript to TypeScript.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.