Development toolchain
In this chapter, you learn about the development tools used in a TypeScript project.
What you learn in this chapter
- Setting up and configuring ESLint
- Integrating with Prettier (also introducing Biome as a unified alternative)
- How to run TypeScript (tsx / Node.js type stripping)
- Testing with Vitest and Jest
- Common matchers and asynchronous testing
Configuring development tools properly lets you maintain code quality and catch bugs early. It is especially important in team development.
What is ESLint
ESLint is a static analysis tool for JavaScript/TypeScript. It detects problems in your code and helps you maintain a consistent coding style.
Reasons to use ESLint:
- Catch potential bugs early
- Automatically check coding standards
- A consistent code style across the whole team
- Make refactoring efficient with the auto-fix feature
Setting up ESLint
# Install ESLint and TypeScript-related packages
npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# Create the ESLint config file (interactive)
npx eslint --init
eslint.config.js (Flat Config format):
// eslint.config.js
// Import ESLint's recommended config
import eslint from '@eslint/js';
// Import the ESLint config for TypeScript
import tseslint from 'typescript-eslint';
// Export the config
export default tseslint.config(
// Apply ESLint's recommended rules
eslint.configs.recommended,
// Apply TypeScript's recommended rules
...tseslint.configs.recommended,
{
// Target files (.ts and .tsx files)
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
// Use the TypeScript parser
parser: tseslint.parser,
parserOptions: {
// Reference tsconfig.json (for rules that use type information)
project: './tsconfig.json',
},
},
rules: {
// Warn on unused variables
'@typescript-eslint/no-unused-vars': 'warn',
// Warn on the use of any
'@typescript-eslint/no-explicit-any': 'warn',
// Require explicit return types (disable with off)
'@typescript-eslint/explicit-function-return-type': 'off',
// Require semicolons (at the error level)
'semi': ['error', 'always'],
// Use single quotes
'quotes': ['error', 'single'],
},
},
{
// Files/folders to exclude
ignores: ['dist/', 'node_modules/', '*.js'],
}
);
Rule setting values:
| Value | Meaning |
|---|---|
'error' | a rule violation is an error (build fails) |
'warn' | a rule violation is a warning (the build passes) |
'off' | the rule is disabled |
Common ESLint rules
// An example of a recommended config
rules: {
// ===== TypeScript-related =====
// Make unused variables an error
// argsIgnorePattern: ignore arguments that start with _
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_', // ignore arguments that start with _
varsIgnorePattern: '^_' // ignore variables that start with _
}],
// Forbid the use of the any type
'@typescript-eslint/no-explicit-any': 'error',
// Recommend ?? (nullish coalescing)
'@typescript-eslint/prefer-nullish-coalescing': 'error',
// Recommend ?. (optional chaining)
'@typescript-eslint/prefer-optional-chain': 'error',
// Forbid unhandled Promises
'@typescript-eslint/no-floating-promises': 'error',
// ===== General rules =====
// Warn on console.log (it should be removed in production code)
'no-console': 'warn',
// Require === (forbid ==)
'eqeqeq': 'error',
// Forbid the use of var (use let/const)
'no-var': 'error',
// Make variables that are not reassigned const
'prefer-const': 'error',
}
What is Prettier
Prettier is a code formatter. It formats code automatically to maintain a consistent style.
The difference between ESLint and Prettier:
| Tool | Role | Example |
|---|---|---|
| ESLint | checking code quality | detecting unused variables, finding potential bugs |
| Prettier | formatting code | unifying indentation, line breaks, and quotes |
Setting up Prettier
# Install Prettier and the ESLint integration package
npm install --save-dev prettier eslint-config-prettier
.prettierrc (Prettier's config file):
{
"semi": true, // add semicolons at the end of statements
"singleQuote": true, // use single quotes
"tabWidth": 2, // set the tab width to 2 spaces
"useTabs": false, // use spaces, not tabs
"trailingComma": "es5", // add trailing commas (ES5-compatible)
"printWidth": 100, // the maximum line width
"bracketSpacing": true, // add spaces inside object braces {}
"arrowParens": "avoid" // omit parentheses for single-argument arrow functions
}
.prettierignore (exclude from formatting):
dist
node_modules
coverage
*.min.js
Integrating ESLint and Prettier
When you use ESLint and Prettier together, you need to avoid config conflicts.
// Add to eslint.config.js
// Import eslint-config-prettier (disables rules that conflict with Prettier)
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
// ... other config
eslintConfigPrettier, // always add it last (disables rules that conflict with Prettier)
];
The scripts in package.json:
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,json}\"",
"check": "prettier --check \"src/**/*.{ts,tsx,json}\""
}
}
The --ext option is unnecessary with Flat Config
Because files: ['**/*.ts', '**/*.tsx'] in eslint.config.js specifies the target files, the 2026 best practice is to run eslint . without --ext on the command side. Note that --ext was temporarily unusable after the move to flat config, but it was reintroduced in ESLint 9.21 as an option to "specify additional extensions." However, if you specify them with files, it is usually unnecessary.
# Run the lint check (detect problems)
npm run lint
# Auto-fix (automatically fix fixable problems)
npm run lint:fix
# Run the formatter (format the code)
npm run format
Biome (a unified alternative to ESLint + Prettier)
Biome is a fast Linter + Formatter written in Rust. v2 reached GA in 2025, and it is widely adopted as an option that replaces ESLint + Prettier + (an import sorter) with a single tool.
Features of Biome:
- All in one tool: the Linter and Formatter are unified, so a single config file is enough
- Fast: written in Rust, it feels more than 10 times faster than an ESLint + Prettier setup
- Zero config: usable right away with
biome init. Supports TypeScript / JSX / JSON / CSS - Official LSP / VSCode extension: editor integration is simple
Installation
# Add it as a devDependency
npm install -D --save-exact @biomejs/biome
# Generate the initial config file (biome.json)
npx biome init
Basic settings in biome.json
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": ["**/*.{ts,tsx,js,jsx,json}"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
Example scripts in package.json
{
"scripts": {
"lint": "biome lint .",
"format": "biome format --write .",
"check": "biome check --write ."
}
}
biome check runs Lint + Format at the same time.
When to use ESLint + Prettier versus Biome
| Aspect | ESLint + Prettier | Biome |
|---|---|---|
| Speed | slower (runs on Node.js) | fast (runs in Rust) |
| Number of rules | abundant (many plugins) | built-in rules only (but covers the main rules) |
| Plugin ecosystem | mature (React / Vue / Vitest, and so on) | from v2, GritQL-based Linter plugins are available, but still limited |
| Config complexity | high (eslint + prettier + related config) | low (a single biome.json file) |
| When it fits | when you need fine-grained rule customization, existing projects | new projects, when you want a faster CI, when you want a simpler config |
As of 2026, a common split is Biome for new projects, ESLint + Prettier for large existing projects. In areas with a rich ecosystem, such as around React, you often depend on ESLint plugins (eslint-plugin-react, eslint-plugin-react-hooks, eslint-plugin-react-compiler, and so on). For that reason, ESLint remains a strong option as well.
How to run TypeScript (build tools)
The ways to run TypeScript code have diversified as of 2026. It is common to choose the right one for each use case.
| Method | Characteristics | When it fits |
|---|---|---|
tsc | the official compiler. Outputs JS files | production builds, type checking only |
tsx | a fast esbuild-based TS runner. tsx watch is the equivalent of HMR | direct execution during development, the successor to ts-node |
| Node.js 22.18+ / 24 (no flags) | strips the types and runs directly (type stripping). No external dependencies | lightweight CLIs, learning, scripts |
| Bun | an all-in-one runtime (direct TS execution / bundling / testing / package management) | speeding up new projects |
| Deno | secure, TS-native | projects in the Deno ecosystem |
| esbuild / swc | fast build tools. Often embedded inside libraries | bundling large projects |
Node.js type stripping (Node.js 22+)
From Node.js 22, an experimental feature was added that officially runs .ts files by stripping the types. Because TypeScript works without additional tools, it is ideal as a learning environment.
# Strip the types and run (no type checking)
node --experimental-strip-types script.ts
# In addition to types, also transform enums and decorators (broader compatibility)
node --experimental-transform-types script.ts
--experimental-strip-types only supports code that runs as JavaScript once the type annotations are removed (code that conforms to erasableSyntaxOnly). You cannot use TS-specific syntax that generates runtime code, such as enums or constructor parameter properties. Combining it with erasableSyntaxOnly from Chapter 17 to align the constraints is safe.
This feature is expected to stabilize in Node.js 24 and later. As of April 2026, the --experimental- prefix is required.
Running during development with tsx
tsx is an esbuild-based runner that runs TypeScript quickly. It is widely adopted as the successor to ts-node / ts-node-dev.
# Install
npm install -D tsx
# Run directly
npx tsx src/index.ts
# Watch for file changes and re-run automatically (the equivalent of ts-node-dev --respawn)
npx tsx watch src/index.ts
It supports both ESM and CJS, and a notable feature is that no config file is needed.
Choosing a test framework (2026)
The main test frameworks you can use in a TypeScript project are as follows.
| Framework | Characteristics | When it fits |
|---|---|---|
| Vitest | a fast test runner that follows Vite's design. Supports TS out of the box | new projects, Vite-based frontends |
| Jest | long-established and proven. Rich in plugins and guides | existing Jest projects, React Native |
Node.js built-in test (node:test) | no extra dependencies, stable on Node.js 22+ | lightweight CLIs, Node.js-native setups |
| Bun Test | Bun's built-in fast test | projects that use Bun |
For new projects in 2026, Vitest is the first choice. Its config is concise, and it handles ESM / TS / JSX as is, which gives it an edge over Jest. However, when you need a plugin that only Jest has, or React Native support, choose Jest.
Vitest (recommended)
Vitest is a test framework developed by the same team as Vite, and it can share Vite's config directly. It handles TypeScript / JSX / ESM with no config and has a Jest-compatible API (describe / it / expect), which keeps the learning curve low.
Installing Vitest
npm install -D vitest @vitest/coverage-v8
The scripts in package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
The config file (optional)
You can create a vitest.config.ts to fine-tune the behavior. It works even without one.
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node', // you can also specify 'jsdom' / 'happy-dom'
globals: false, // set to true if you want describe / it to be global
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
})
An example of test code
// src/calculator.test.ts
import { describe, it, expect } from 'vitest'
import { Calculator } from './calculator'
describe('Calculator', () => {
it('add should sum two numbers', () => {
const calc = new Calculator()
expect(calc.add(2, 3)).toBe(5)
})
})
Run:
npm test # start in watch mode
npm run test:run # run once (for CI)
npm run test:coverage # generate a coverage report
What is Jest
Jest is a test framework for JavaScript/TypeScript developed by Facebook (now Meta). It has a long track record and rich existing documentation and plugins.
We recommend Vitest for new projects, but when you have React Native or a Jest-dependent toolchain, continue to use Jest. Read the explanation below as a reference for learning Jest.
Features of Jest:
- Works almost immediately without config (zero config)
- Fast test execution (parallel execution)
- Snapshot testing
- Built-in coverage reports
- A rich mocking feature
Setting up Jest
# Install Jest and TypeScript-related packages
npm install --save-dev jest ts-jest @types/jest
# Generate the Jest config file
npx ts-jest config:init
jest.config.js:
const { createDefaultPreset } = require('ts-jest');
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
// Use ts-jest's transform for the TypeScript transformation
// (the string form preset: 'ts-jest' is deprecated)
transform: {
...createDefaultPreset().transform,
},
// The test environment (Node.js)
testEnvironment: 'node',
// The pattern for test files
testMatch: [
'**/__tests__/**/*.ts', // .ts files in the __tests__ folder
'**/*.test.ts', // *.test.ts
'**/*.spec.ts' // *.spec.ts
],
// Coverage collection targets
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts', // exclude type definition files
'!src/index.ts' // exclude the entry point
],
// Path mapping for module resolution
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
};
The basics of testing
First, create the code under test.
// src/calculator.ts
export class Calculator {
// Addition
add(a: number, b: number): number {
return a + b;
}
// Subtraction
subtract(a: number, b: number): number {
return a - b;
}
// Multiplication
multiply(a: number, b: number): number {
return a * b;
}
// Division (division by 0 is an error)
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
}
Create the test file.
// src/calculator.test.ts
import { Calculator } from './calculator';
// describe: group tests (can be nested)
describe('Calculator', () => {
// The instance under test
let calculator: Calculator;
// beforeEach: setup that runs before each test
beforeEach(() => {
// Use a new instance for each test
calculator = new Calculator();
});
// describe: the test group for the add method
describe('add', () => {
// it: an individual test case
it('should add two positive numbers', () => {
// expect: an assertion (verifying the expected value)
// toBe: strict equality (===)
expect(calculator.add(2, 3)).toBe(5);
});
it('should add negative numbers', () => {
expect(calculator.add(-1, -2)).toBe(-3);
});
it('should add zero', () => {
expect(calculator.add(5, 0)).toBe(5);
});
});
describe('subtract', () => {
it('should subtract two numbers', () => {
expect(calculator.subtract(5, 3)).toBe(2);
});
it('should handle negative result', () => {
expect(calculator.subtract(3, 5)).toBe(-2);
});
});
describe('multiply', () => {
it('should multiply two numbers', () => {
expect(calculator.multiply(3, 4)).toBe(12);
});
it('should return zero when multiplied by zero', () => {
expect(calculator.multiply(5, 0)).toBe(0);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(calculator.divide(10, 2)).toBe(5);
});
// When testing an exception
it('should throw error when dividing by zero', () => {
// toThrow: verify that an exception is thrown
expect(() => calculator.divide(10, 0)).toThrow('Division by zero');
});
it('should handle decimal results', () => {
expect(calculator.divide(7, 2)).toBe(3.5);
});
});
});
The structure of a test:
describe: group tests (can be nested)it/test: define an individual test caseexpect: an assertion (verifying the expected value)beforeEach: processing that runs before each test
Common matchers
describe('Jest Matchers', () => {
// ===== Equality =====
it('equality matchers', () => {
expect(2 + 2).toBe(4); // strict equality (===)
expect({ a: 1 }).toEqual({ a: 1 }); // deep equality (object comparison)
expect(null).toBeNull(); // null check
expect(undefined).toBeUndefined(); // undefined check
expect('hello').toBeDefined(); // defined check
});
// ===== Truthiness =====
it('truthiness matchers', () => {
expect(true).toBeTruthy(); // a truthy value
expect(false).toBeFalsy(); // a falsy value
expect(0).toBeFalsy(); // 0 is falsy
expect('').toBeFalsy(); // an empty string is falsy
expect(1).toBeTruthy(); // 1 is truthy
});
// ===== Numbers =====
it('number matchers', () => {
expect(10).toBeGreaterThan(5); // greater than
expect(5).toBeGreaterThanOrEqual(5); // greater than or equal
expect(3).toBeLessThan(5); // less than
expect(0.1 + 0.2).toBeCloseTo(0.3); // approximate comparison for floating point
});
// ===== Strings =====
it('string matchers', () => {
expect('Hello World').toContain('World'); // contains a substring
expect('TypeScript').toMatch(/Script$/); // regex match
expect('hello').toHaveLength(5); // length
});
// ===== Arrays =====
it('array matchers', () => {
const fruits = ['apple', 'banana', 'orange'];
expect(fruits).toContain('banana'); // contains an element
expect(fruits).toHaveLength(3); // array length
// contains a sub-array
expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 2]));
});
// ===== Objects =====
it('object matchers', () => {
const user = { name: 'Alice', age: 25, email: 'alice@example.com' };
expect(user).toHaveProperty('name'); // has a property
expect(user).toHaveProperty('age', 25); // a property and value
expect(user).toMatchObject({ name: 'Alice', age: 25 }); // a partial match
});
// ===== Exceptions =====
it('exception matchers', () => {
const throwError = () => {
throw new Error('Something went wrong');
};
expect(throwError).toThrow(); // throws an exception
expect(throwError).toThrow('Something went wrong'); // a specific message
expect(throwError).toThrow(Error); // a specific type
});
});
Asynchronous testing
Let's learn how to test asynchronous functions.
// src/api.ts
// An asynchronous function that fetches user information (a mock API)
export async function fetchUser(id: number): Promise<{ id: number; name: string }> {
// In reality, this calls an API
return new Promise(resolve => {
// Return the result after 100 milliseconds
setTimeout(() => {
resolve({ id, name: `User ${id}` });
}, 100);
});
}
// A callback-style API (for legacy code)
export function fetchUserCallback(
id: number,
callback: (user: { id: number; name: string }) => void
): void {
setTimeout(() => {
callback({ id, name: `User ${id}` });
}, 100);
}
// src/api.test.ts
import { fetchUser, fetchUserCallback } from './api';
describe('Async Tests', () => {
// Pattern 1: async/await (recommended)
it('should fetch user with async/await', async () => {
// Wait for the async processing to complete with await
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: 'User 1' });
});
// Pattern 2: return a Promise
it('should fetch user with Promise', () => {
// Return the Promise with return (Jest waits for completion)
return fetchUser(2).then(user => {
expect(user).toEqual({ id: 2, name: 'User 2' });
});
});
// Pattern 3: resolves/rejects matchers
it('should resolve with user data', async () => {
// Verify the resolved value of the Promise with resolves
await expect(fetchUser(3)).resolves.toEqual({ id: 3, name: 'User 3' });
});
// Pattern 4: a callback (using done)
it('should fetch user with callback', done => {
fetchUserCallback(4, user => {
expect(user).toEqual({ id: 4, name: 'User 4' });
done(); // notify that the test is complete (without this, it times out)
});
});
});
Mocks
Mock external dependencies (such as API calls) to isolate the test.
// src/userService.ts
import { fetchUser } from './api';
export class UserService {
// Get a user's name
async getUserName(id: number): Promise<string> {
const user = await fetchUser(id);
return user.name;
}
// Get the names of multiple users
async getUsersNames(ids: number[]): Promise<string[]> {
const users = await Promise.all(ids.map(id => fetchUser(id)));
return users.map(u => u.name);
}
}
// src/userService.test.ts
import { UserService } from './userService';
import { fetchUser } from './api';
// Mock the entire api module
// The actual API call is not made
jest.mock('./api');
// The type definition for the mocked fetchUser
const mockedFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
// Reset the mock before each test
jest.clearAllMocks();
});
it('should get user name', async () => {
// Set the mock's return value
mockedFetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
const name = await userService.getUserName(1);
expect(name).toBe('Alice');
// Verify the mock was called with the correct argument
expect(mockedFetchUser).toHaveBeenCalledWith(1);
// Verify the mock was called exactly once
expect(mockedFetchUser).toHaveBeenCalledTimes(1);
});
it('should get multiple user names', async () => {
// When called multiple times, return a different value each time
mockedFetchUser
.mockResolvedValueOnce({ id: 1, name: 'Alice' })
.mockResolvedValueOnce({ id: 2, name: 'Bob' })
.mockResolvedValueOnce({ id: 3, name: 'Charlie' });
const names = await userService.getUsersNames([1, 2, 3]);
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
expect(mockedFetchUser).toHaveBeenCalledTimes(3);
});
it('should handle API error', async () => {
// Simulate an error
mockedFetchUser.mockRejectedValue(new Error('API Error'));
// Verify the error with rejects
await expect(userService.getUserName(1)).rejects.toThrow('API Error');
});
});
Key mock methods:
| Method | Description |
|---|---|
jest.mock('./module') | mock the entire module |
mockResolvedValue(value) | set the value the Promise resolves to |
mockRejectedValue(error) | set the value the Promise rejects with |
mockResolvedValueOnce(value) | return a specific value only once |
toHaveBeenCalledWith(args) | verify it was called with specific arguments |
toHaveBeenCalledTimes(n) | verify the number of calls |
Test coverage
You can measure how much of the code your tests cover.
# Generate a coverage report
npm test -- --coverage
package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Coverage threshold settings (jest.config.js):
module.exports = {
// ... other config
// Coverage threshold settings
coverageThreshold: {
global: {
branches: 80, // branch coverage 80% or more
functions: 80, // function coverage 80% or more
lines: 80, // line coverage 80% or more
statements: 80, // statement coverage 80% or more
},
},
// The format of the coverage report
coverageReporters: ['text', 'lcov', 'html'],
};
Try it: write a unit test ★★
Write tests for the following StringUtils class.
// src/stringUtils.ts
export class StringUtils {
// Reverse a string
reverse(str: string): string {
return str.split('').reverse().join('');
}
// Determine whether a string is a palindrome
isPalindrome(str: string): boolean {
const cleaned = str.toLowerCase().replace(/[^a-z0-9]/g, '');
return cleaned === this.reverse(cleaned);
}
// Count the words
countWords(str: string): number {
if (!str.trim()) return 0;
return str.trim().split(/\s+/).length;
}
}
Cases you should test:
reverse: a normal string, an empty string, a single characterisPalindrome: a palindrome, a non-palindrome, a palindrome with spaces and punctuationcountWords: a normal sentence, an empty string, multiple spaces
Hint
- Group the class name and method names with
describe - Create the instance with
beforeEach - Test the normal cases and boundary values for each method
- Use matchers such as
toBe,toBeTruthy, andtoBeFalsy
Answer and explanation
// src/stringUtils.test.ts
import { StringUtils } from './stringUtils';
describe('StringUtils', () => {
let utils: StringUtils;
// Create a new instance before each test
beforeEach(() => {
utils = new StringUtils();
});
// Tests for the reverse method
describe('reverse', () => {
it('should reverse a normal string', () => {
expect(utils.reverse('hello')).toBe('olleh');
});
it('should handle empty string', () => {
expect(utils.reverse('')).toBe('');
});
it('should handle single character', () => {
expect(utils.reverse('a')).toBe('a');
});
it('should reverse Japanese characters', () => {
expect(utils.reverse('あいう')).toBe('ういあ');
});
});
// Tests for the isPalindrome method
describe('isPalindrome', () => {
it('should return true for palindrome', () => {
expect(utils.isPalindrome('racecar')).toBe(true);
});
it('should return false for non-palindrome', () => {
expect(utils.isPalindrome('hello')).toBe(false);
});
it('should ignore case', () => {
expect(utils.isPalindrome('RaceCar')).toBe(true);
});
it('should ignore spaces and punctuation', () => {
expect(utils.isPalindrome('A man, a plan, a canal: Panama')).toBe(true);
});
it('should handle empty string', () => {
expect(utils.isPalindrome('')).toBe(true);
});
it('should handle single character', () => {
expect(utils.isPalindrome('a')).toBe(true);
});
});
// Tests for the countWords method
describe('countWords', () => {
it('should count words in a normal sentence', () => {
expect(utils.countWords('Hello World')).toBe(2);
});
it('should return 0 for empty string', () => {
expect(utils.countWords('')).toBe(0);
});
it('should return 0 for whitespace only', () => {
expect(utils.countWords(' ')).toBe(0);
});
it('should handle multiple spaces between words', () => {
expect(utils.countWords('hello world')).toBe(2);
});
it('should handle leading and trailing spaces', () => {
expect(utils.countWords(' hello world ')).toBe(2);
});
it('should count single word', () => {
expect(utils.countWords('hello')).toBe(1);
});
});
});
Explanation:
- The class name and method names are nested and grouped with
describeto make the test structure clear - For each method, we test normal cases, boundary values (empty string, single character), and edge cases (spaces and punctuation)
beforeEachcreates a new instance each time to prevent tests from affecting one another- Test names are written in the form "should + expected behavior" to make it clear what is being tested
Running the tests:
# Run the tests
npm test
# Run with coverage
npm test -- --coverage
Summary
What you learned in this chapter:
- ESLint: checking code quality and maintaining a consistent style
- Prettier: formatting code automatically
- Integrating ESLint and Prettier: resolving conflicts between the two tools
- Biome: a fast option that replaces ESLint + Prettier with a single tool
- Running TypeScript: tsx, and Node.js 22.18+/24 type stripping
- Vitest: the first-choice test framework for new projects
- Jest: setting up the test framework
- The basics of testing: describe, it, expect, beforeEach
- Matchers: toBe, toEqual, toThrow, toContain, and so on
- Asynchronous testing: async/await, Promise, done
- Mocks: mocking external dependencies and isolating tests
In the next chapter, you will learn about React + TypeScript.
Common pitfalls for beginners
Common mistakes
❌ ESLint and Prettier rules conflict
// eslint.config.js
// ❌ Enabling rules that conflict with Prettier
rules: {
'indent': ['error', 2], // conflicts with Prettier
'quotes': ['error', 'single'], // conflicts with Prettier
}
// ✅ Add eslint-config-prettier last
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
// ... other config
eslintConfigPrettier, // disables conflicting rules
];
Cause: ESLint and Prettier have the same formatting rules and can conflict.
Solution: Add eslint-config-prettier last in the config to disable the conflicting rules.
❌ Not catching async/await errors in Jest
// ❌ Forgetting await can let the test pass
it('should throw error', () => {
expect(asyncFunction()).rejects.toThrow(); // no await!
});
// ✅ Add await
it('should throw error', async () => {
await expect(asyncFunction()).rejects.toThrow();
});
Cause: The rejects/resolves matchers return a Promise, so await is required.
Solution: Always use async/await in asynchronous tests.
❌ State is shared between tests
// ❌ State is shared between tests
let counter = 0;
describe('Counter', () => {
it('should increment', () => {
counter++;
expect(counter).toBe(1);
});
it('should be zero', () => {
expect(counter).toBe(0); // Fail! counter is still 1
});
});
// ✅ Reset with beforeEach
describe('Counter', () => {
let counter: number;
beforeEach(() => {
counter = 0; // reset before each test
});
// ...
});
Cause: When variables are shared between tests, the result depends on the execution order.
Solution: Initialize the state with beforeEach to make tests independent.
❌ Not resetting a mocked module
// ❌ The mock state lingers
jest.mock('./api');
it('test 1', () => {
(fetchData as jest.Mock).mockResolvedValue({ id: 1 });
// ...
});
it('test 2', () => {
// the mock setup from test 1 may linger
});
// ✅ Reset after each test
afterEach(() => {
jest.clearAllMocks(); // clear the mock call history
// or
jest.resetAllMocks(); // also reset the mock implementation
});
Cause: Mock state lingers unless you reset it explicitly.
Solution: Call jest.clearAllMocks() in afterEach or beforeEach.
❌ Comparing objects with toBe()
// ❌ toBe is a reference comparison
const obj1 = { a: 1 };
const obj2 = { a: 1 };
expect(obj1).toBe(obj2); // Fail! the references differ
// ✅ Compare values with toEqual
expect(obj1).toEqual(obj2); // Pass! the values are the same
Cause: toBe compares with ===, so objects are false unless the references are the same.
Solution: Use toEqual to compare object values, and toBe for primitives.
What to read next
Continue to Practice. You do a comprehensive set of exercises with React + TypeScript, an Express backend, and a final project.