@@ -3,197 +3,4 @@ description: Writing unit tests with `jest`
3
3
globs: tests/**/*
4
4
alwaysApply: false
5
5
---
6
- # Testing Guidelines for TypeScript + ES Modules + Jest
7
6
8
- This guide contains cumulative in-context learnings about working with this project's testing stack.
9
-
10
- ## Unit vs. Integration Tests
11
-
12
- **Never Mix Test Types**: Separate integration tests from unit tests into different files:
13
- - Simple unit tests without mocks for validating rules (like state transitions)
14
- - Integration tests with mocks for filesystem and external dependencies
15
-
16
- ## File Path Handling in Tests
17
-
18
- 1. **Environment Variables**:
19
- - Use `process.env.TASK_MANAGER_FILE_PATH` for configuring file paths in tests
20
- - Set this in `beforeEach` and clean up in `afterEach`:
21
- ```typescript
22
- beforeEach(async () => {
23
- tempDir = path.join(os.tmpdir(), `test-${Date.now()}`);
24
- await fs.mkdir(tempDir, { recursive: true });
25
- tasksFilePath = path.join(tempDir, "test-tasks.json");
26
- process.env.TASK_MANAGER_FILE_PATH = tasksFilePath;
27
- });
28
-
29
- afterEach(async () => {
30
- await fs.rm(tempDir, { recursive: true, force: true });
31
- delete process.env.TASK_MANAGER_FILE_PATH;
32
- });
33
- ```
34
-
35
- 2. **Temporary Files**:
36
- - Create unique temp directories for each test run
37
- - Use `os.tmpdir()` for platform-independent temp directories
38
- - Include timestamps in directory names to prevent conflicts
39
- - Always clean up temp files in `afterEach`
40
-
41
- ## Jest ESM Mocking, Step-by-Step
42
-
43
- 1. **Type-Only Import:**
44
- Import types for static analysis without actually executing the module code:
45
- ```typescript
46
- import type { MyService as MyServiceType } from 'path/to/MyService.js';
47
- import type { readFile as ReadFileType } from 'node:fs/promises';
48
- ```
49
-
50
- 2. **Register Mock:**
51
- Use `jest.unstable_mockModule` to replace the real module:
52
- ```typescript
53
- jest.unstable_mockModule('node:fs/promises', () => ({
54
- __esModule: true,
55
- readFile: jest.fn(),
56
- }));
57
- ```
58
-
59
- 3. **Set Default Mock Implementations, Then Dynamically Import Modules:**
60
- You must dynamically import the modules to be mocked and/or tested *after* registering mocks and setting any mock implementations. This ensures that when `MyService` attempts to import `node:fs/promises`, it gets your mocked version. Depending how you want to scope your mock implementations, you can do this in `beforeAll`, `beforeEach`, or at the top of each test.
61
- ```typescript
62
- let MyService: typeof MyServiceType;
63
- let readFile: jest.MockedFunction<ReadFileType>;
64
-
65
- beforeAll(async () => {
66
- const fsPromisesMock = await import('node:fs/promises');
67
- readFile = fsPromisesMock.readFile as jest.MockedFunction<ReadFileType>;
68
-
69
- // Set default implementation
70
- readFile.mockResolvedValue('default mocked content');
71
-
72
- const serviceModule = await import('path/to/MyService.js');
73
- MyService = serviceModule.MyService;
74
- });
75
- ```
76
-
77
- 4. **Setup in `beforeEach`:**
78
- Reset mocks and set default behaviors before each test:
79
- ```typescript
80
- beforeEach(() => {
81
- jest.clearAllMocks();
82
- readFile.mockResolvedValue('');
83
- });
84
- ```
85
-
86
- 5. **Write a Test:**
87
- Now you can test your service with the mocked `readFile`:
88
- ```typescript
89
- describe('MyService', () => {
90
- let myServiceInstance: MyServiceType;
91
-
92
- beforeEach(() => {
93
- myServiceInstance = new MyService('somePath');
94
- });
95
-
96
- it('should do something', async () => {
97
- readFile.mockResolvedValueOnce('some data');
98
- const result = await myServiceInstance.someMethod();
99
- expect(result).toBe('expected result');
100
- expect(readFile).toHaveBeenCalledWith('somePath', 'utf-8');
101
- });
102
- });
103
- ```
104
-
105
- ### Mocking a Class with Methods
106
-
107
- If you have a class `MyClass` that has both instance methods and static methods, you can mock it in an **ES Modules + TypeScript** setup using the same pattern. For instance:
108
-
109
- ```typescript
110
- // 1. Create typed jest mock functions using the original types
111
- type InitResult = { data: string };
112
-
113
- const mockInit = jest.fn() as jest.MockedFunction<MyClass['init']>;
114
- const mockDoWork = jest.fn() as jest.MockedFunction<MyClass['doWork']>;
115
- const mockStaticHelper = jest.fn() as jest.MockedFunction<typeof MyClass.staticHelper>;
116
-
117
- // 2. Use jest.unstable_mockModule with an ES6 class in the factory
118
- jest.unstable_mockModule('path/to/MyClass.js', () => {
119
- class MockMyClass {
120
- // Instance methods
121
- init = mockInit;
122
- doWork = mockDoWork;
123
-
124
- // Static method
125
- static staticHelper = mockStaticHelper;
126
- }
127
-
128
- return {
129
- __esModule: true,
130
- MyClass: MockMyClass, // same name/structure as real export
131
- };
132
- });
133
-
134
- // 3. Import your class after mocking
135
- let MyClass: typeof import('path/to/MyClass.js')['MyClass'];
136
-
137
- beforeAll(async () => {
138
- const myClassModule = await import('path/to/MyClass.js');
139
- MyClass = myClassModule.MyClass;
140
- });
141
-
142
- // 4. Write tests and reset mocks
143
- beforeEach(() => {
144
- jest.clearAllMocks();
145
- mockInit.mockResolvedValue({ data: 'default' });
146
- mockStaticHelper.mockReturnValue(42);
147
- });
148
-
149
- describe('MyClass', () => {
150
- it('should call init', async () => {
151
- const instance = new MyClass();
152
- const result = await instance.init();
153
- expect(result).toEqual({ data: 'default' });
154
- expect(mockInit).toHaveBeenCalledTimes(1);
155
- });
156
-
157
- it('should call the static helper', () => {
158
- const val = MyClass.staticHelper();
159
- expect(val).toBe(42);
160
- expect(mockStaticHelper).toHaveBeenCalledTimes(1);
161
- });
162
- });
163
- ```
164
-
165
- ### Best Practice: **Type** Your Mocked Functions
166
-
167
- By default, `jest.fn()` is very generic and doesn't enforce parameter or return types. This can cause TypeScript errors like:
168
-
169
- > `Argument of type 'undefined' is not assignable to parameter of type 'never'`
170
-
171
- or
172
-
173
- > `Type 'Promise<SomeType>' is not assignable to type 'FunctionLike'`
174
-
175
- To avoid these, **use the original type with `jest.MockedFunction`**. For example, if your real function is:
176
-
177
- ```typescript
178
- async function loadStuff(id: string): Promise<string[]> {
179
- // ...
180
- }
181
- ```
182
-
183
- then you should type the mock as:
184
-
185
- ```typescript
186
- const mockLoadStuff = jest.fn() as jest.MockedFunction<typeof loadStuff>;
187
- ```
188
-
189
- For class methods, use the class type to get the method signature:
190
-
191
- ```typescript
192
- const mockClassMethod = jest.fn() as jest.MockedFunction<YourClass['classMethod']>;
193
- ```
194
-
195
- This helps TypeScript catch mistakes if you:
196
- - call the function with the wrong argument types
197
- - use `mockResolvedValue` with the wrong shape
198
-
199
- Once typed properly, your `mockResolvedValue(...)`, `mockImplementation(...)`, etc. calls will be fully type-safe.
0 commit comments