Skip to content

Commit cb6cea3

Browse files
committed
feat(docs): add the angular testing documentation
1 parent 812fadf commit cb6cea3

25 files changed

+2563
-154
lines changed

docs/config.json

+8
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,10 @@
707707
"label": "Default Query Fn",
708708
"to": "framework/angular/guides/default-query-function"
709709
},
710+
{
711+
"label": "Testing",
712+
"to": "framework/angular/guides/testing"
713+
},
710714
{
711715
"label": "Does this replace state managers?",
712716
"to": "framework/angular/guides/does-this-replace-client-state"
@@ -1283,6 +1287,10 @@
12831287
{
12841288
"label": "Devtools embedded panel",
12851289
"to": "framework/angular/examples/devtools-panel"
1290+
},
1291+
{
1292+
"label": "Unit Testing / Jest",
1293+
"to": "framework/angular/examples/unit-testing"
12861294
}
12871295
]
12881296
}
+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
---
2+
id: testing
3+
title: Testing
4+
---
5+
6+
As there is currently no simple way to await a signal to reach a specific value we will use polling to wait in our test (instead of transforming our signals in observable and use RxJS features to filter the values). If you want to do like us for the polling you can use the angular testing library.
7+
8+
Install this by running:
9+
10+
```sh
11+
ng add @testing-library/angular
12+
```
13+
14+
Otherwise we recommend to use the toObservable feature from Angular.
15+
16+
## What to test
17+
18+
Because the recommendation is to use services that provide the Query options through function this is what we are going to do.
19+
20+
## A simple test
21+
22+
```ts
23+
//tasks.service.ts
24+
import { HttpClient } from '@angular/common/http'
25+
import { Injectable, inject } from '@angular/core'
26+
import {
27+
QueryClient,
28+
mutationOptions,
29+
queryOptions,
30+
} from '@tanstack/angular-query-experimental'
31+
32+
import { lastValueFrom } from 'rxjs'
33+
34+
@Injectable({
35+
providedIn: 'root',
36+
})
37+
export class TasksService {
38+
#queryClient = inject(QueryClient) // Manages query state and caching
39+
#http = inject(HttpClient) // Handles HTTP requests
40+
41+
/**
42+
* Fetches all tasks from the API.
43+
* Returns an observable containing an array of task strings.
44+
*/
45+
allTasks = () =>
46+
queryOptions({
47+
queryKey: ['tasks'],
48+
queryFn: () => {
49+
return lastValueFrom(this.#http.get<Array<string>>('/api/tasks'));
50+
}
51+
})
52+
}
53+
```
54+
55+
```ts
56+
// tasks.service.spec.ts
57+
import { TestBed } from "@angular/core/testing";
58+
import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http";
59+
import { QueryClient, injectQuery, provideTanStackQuery } from "@tanstack/angular-query-experimental";
60+
import { Injector, inject, runInInjectionContext } from "@angular/core";
61+
import { waitFor } from '@testing-library/angular';
62+
import { mockInterceptor } from "../interceptor/mock-api.interceptor";
63+
import { TasksService } from "./tasks.service";
64+
import type { CreateQueryResult} from "@tanstack/angular-query-experimental";
65+
66+
describe('Test suite: TaskService', () => {
67+
let service!: TasksService;
68+
let injector!: Injector;
69+
70+
// https://angular.dev/guide/http/testing
71+
beforeEach(() => {
72+
TestBed.configureTestingModule({
73+
providers: [
74+
provideHttpClient(withFetch(), withInterceptors([mockInterceptor])),
75+
TasksService,
76+
// It is recommended to cancel the retries in the tests
77+
provideTanStackQuery(new QueryClient({
78+
defaultOptions: {
79+
queries: {
80+
retry: false
81+
}
82+
}
83+
}))
84+
]
85+
});
86+
service = TestBed.inject(TasksService);
87+
injector = TestBed.inject(Injector);
88+
});
89+
90+
it('should get all the Tasks', () => {
91+
let allTasks: any;
92+
runInInjectionContext(injector, () => {
93+
allTasks = injectQuery(() => service.allTasks());
94+
});
95+
expect(allTasks.status()).toEqual('pending');
96+
expect(allTasks.isFetching()).toEqual(true);
97+
expect(allTasks.data()).toEqual(undefined);
98+
// We await the first result from the query
99+
await waitFor(() => expect(allTasks.isFetching()).toBe(false), {timeout: 10000});
100+
expect(allTasks.status()).toEqual('success');
101+
expect(allTasks.data()).toEqual([]); // Considering that the inteceptor is returning [] at the first query request.
102+
// To have a more complete example have a look at "unit testing / jest"
103+
});
104+
});
105+
```
106+
107+
```ts
108+
// mock-api.interceptor.ts
109+
/**
110+
* MockApiInterceptor is used to simulate API responses for `/api/tasks` endpoints.
111+
* It handles the following operations:
112+
* - GET: Fetches all tasks from sessionStorage.
113+
* - POST: Adds a new task to sessionStorage.
114+
* Simulated responses include a delay to mimic network latency.
115+
*/
116+
import { HttpResponse } from '@angular/common/http'
117+
import { delay, of, throwError } from 'rxjs'
118+
import type {
119+
HttpEvent,
120+
HttpHandlerFn,
121+
HttpInterceptorFn,
122+
HttpRequest,
123+
} from '@angular/common/http'
124+
import type { Observable } from 'rxjs'
125+
126+
export const mockInterceptor: HttpInterceptorFn = (
127+
req: HttpRequest<unknown>,
128+
next: HttpHandlerFn,
129+
): Observable<HttpEvent<any>> => {
130+
const respondWith = (status: number, body: any) =>
131+
of(new HttpResponse({ status, body })).pipe(delay(1000))
132+
if (req.url === '/api/tasks') {
133+
switch (req.method) {
134+
case 'GET':
135+
return respondWith(
136+
200,
137+
JSON.parse(
138+
sessionStorage.getItem('unit-testing-tasks') || '[]',
139+
),
140+
)
141+
case 'POST':
142+
const tasks = JSON.parse(
143+
sessionStorage.getItem('unit-testing-tasks') || '[]',
144+
)
145+
tasks.push(req.body)
146+
sessionStorage.setItem(
147+
'unit-testing-tasks',
148+
JSON.stringify(tasks),
149+
)
150+
return respondWith(201, {
151+
status: 'success',
152+
task: req.body,
153+
})
154+
}
155+
}
156+
if (req.url === '/api/tasks-wrong-url') {
157+
return throwError(() => new Error('error')).pipe(delay(1000));
158+
}
159+
160+
return next(req)
161+
}
162+
```
163+
164+
## Turn off retries
165+
166+
The library defaults to three retries with exponential backoff, which means that your tests are likely to timeout if you want to test an erroneous query. The easiest way to turn retries off is via the provideTanStackQuery during the TestBed setup as shown in the above example.
167+
168+
## Testing Network Calls
169+
170+
Instead of targetting a server for the data you should mock the requests. There are multiple way of handling the mocking, we recommend to use the Interceptor from Angular, see [here](https://angular.dev/guide/http/interceptors) for more details.
171+
You can see the the Interceptor setup in the "Unit testing / Jest" examples.

examples/angular/auto-refetching/src/app/services/tasks.service.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class TasksService {
3838
lastValueFrom(this.#http.post('/api/tasks', task)),
3939
mutationKey: ['tasks'],
4040
onSuccess: () => {
41-
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
41+
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
4242
},
4343
})
4444
}
@@ -52,7 +52,7 @@ export class TasksService {
5252
mutationFn: () => lastValueFrom(this.#http.delete('/api/tasks')),
5353
mutationKey: ['clearTasks'],
5454
onSuccess: () => {
55-
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
55+
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
5656
},
5757
})
5858
}

examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts

-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ export class OptimisticUpdatesComponent {
5555
#tasksService = inject(TasksService)
5656

5757
tasks = injectQuery(() => this.#tasksService.allTasks())
58-
clearMutation = injectMutation(() => this.#tasksService.addTask())
5958
addMutation = injectMutation(() => this.#tasksService.addTask())
6059

6160
newItem = ''

examples/angular/optimistic-updates/src/app/interceptor/mock-api.interceptor.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Simulated responses include a delay to mimic network latency.
77
*/
88
import { HttpResponse } from '@angular/common/http'
9-
import { delay, of } from 'rxjs'
9+
import { delay, of, throwError } from 'rxjs'
1010
import type {
1111
HttpEvent,
1212
HttpHandlerFn,
@@ -46,9 +46,7 @@ export const mockInterceptor: HttpInterceptorFn = (
4646
}
4747
}
4848
if (req.url === '/api/tasks-wrong-url') {
49-
return respondWith(500, {
50-
status: 'error',
51-
})
49+
return throwError(() => new Error('error')).pipe(delay(1000));
5250
}
5351

5452
return next(req)

examples/angular/optimistic-updates/src/app/services/tasks.service.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,8 @@ export class TasksService {
4747
),
4848
),
4949
mutationKey: ['tasks'],
50-
onSuccess: () => {
51-
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
52-
},
53-
onMutate: async ({ task }) => {
50+
onSuccess: () => {},
51+
onMutate: async ({ task } : {task: string}) => {
5452
// Cancel any outgoing refetches
5553
// (so they don't overwrite our optimistic update)
5654
await this.#queryClient.cancelQueries({ queryKey: ['tasks'] })
@@ -70,14 +68,14 @@ export class TasksService {
7068

7169
return previousTodos
7270
},
73-
onError: (err, variables, context) => {
71+
onError: (_err: any, _variables: any, context: any) => {
7472
if (context) {
7573
this.#queryClient.setQueryData<Array<string>>(['tasks'], context)
7674
}
7775
},
7876
// Always refetch after error or success:
7977
onSettled: () => {
80-
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
78+
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
8179
},
8280
})
8381
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "Node.js",
3+
"image": "mcr.microsoft.com/devcontainers/javascript-node:22"
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// @ts-check
2+
3+
/** @type {import('eslint').Linter.Config} */
4+
const config = {}
5+
6+
module.exports = config
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# TanStack Query Angular unit-testing example
2+
3+
To run this example:
4+
5+
- `npm install` or `yarn` or `pnpm i` or `bun i`
6+
- `npm run start` or `yarn start` or `pnpm start` or `bun start`
7+
- `npm run test` to run the tests

0 commit comments

Comments
 (0)