Skip to main content

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
info

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:

ValueMeaning
'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:

ToolRoleExample
ESLintchecking code qualitydetecting unused variables, finding potential bugs
Prettierformatting codeunifying 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}\""
}
}
info

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

AspectESLint + PrettierBiome
Speedslower (runs on Node.js)fast (runs in Rust)
Number of rulesabundant (many plugins)built-in rules only (but covers the main rules)
Plugin ecosystemmature (React / Vue / Vitest, and so on)from v2, GritQL-based Linter plugins are available, but still limited
Config complexityhigh (eslint + prettier + related config)low (a single biome.json file)
When it fitswhen you need fine-grained rule customization, existing projectsnew projects, when you want a faster CI, when you want a simpler config
info

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.

MethodCharacteristicsWhen it fits
tscthe official compiler. Outputs JS filesproduction builds, type checking only
tsxa fast esbuild-based TS runner. tsx watch is the equivalent of HMRdirect 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 dependencieslightweight CLIs, learning, scripts
Bunan all-in-one runtime (direct TS execution / bundling / testing / package management)speeding up new projects
Denosecure, TS-nativeprojects in the Deno ecosystem
esbuild / swcfast build tools. Often embedded inside librariesbundling 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
info

--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.

FrameworkCharacteristicsWhen it fits
Vitesta fast test runner that follows Vite's design. Supports TS out of the boxnew projects, Vite-based frontends
Jestlong-established and proven. Rich in plugins and guidesexisting 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 TestBun's built-in fast testprojects that use Bun
info

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 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.

info

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 case
  • expect: 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:

MethodDescription
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:

  1. reverse: a normal string, an empty string, a single character
  2. isPalindrome: a palindrome, a non-palindrome, a palindrome with spaces and punctuation
  3. countWords: a normal sentence, an empty string, multiple spaces
Hint
  1. Group the class name and method names with describe
  2. Create the instance with beforeEach
  3. Test the normal cases and boundary values for each method
  4. Use matchers such as toBe, toBeTruthy, and toBeFalsy
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 describe to make the test structure clear
  • For each method, we test normal cases, boundary values (empty string, single character), and edge cases (spaces and punctuation)
  • beforeEach creates 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

warning

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.


Continue to Practice. You do a comprehensive set of exercises with React + TypeScript, an Express backend, and a final project.