Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,10 @@
"label": "Default Query Fn",
"to": "framework/angular/guides/default-query-function"
},
{
"label": "Testing",
"to": "framework/angular/guides/testing"
},
{
"label": "Does this replace state managers?",
"to": "framework/angular/guides/does-this-replace-client-state"
Expand Down Expand Up @@ -1283,6 +1287,10 @@
{
"label": "Devtools embedded panel",
"to": "framework/angular/examples/devtools-panel"
},
{
"label": "Unit Testing / Jest",
"to": "framework/angular/examples/unit-testing"
}
]
}
Expand Down
171 changes: 171 additions & 0 deletions docs/framework/angular/guides/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
id: testing
title: Testing
---

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.

Install this by running:

```sh
ng add @testing-library/angular
```

Otherwise we recommend to use the toObservable feature from Angular.

## What to test

Because the recommendation is to use services that provide the Query options through function this is what we are going to do.

## A simple test

```ts
//tasks.service.ts
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import {
QueryClient,
mutationOptions,
queryOptions,
} from '@tanstack/angular-query-experimental'

import { lastValueFrom } from 'rxjs'

@Injectable({
providedIn: 'root',
})
export class TasksService {
#queryClient = inject(QueryClient) // Manages query state and caching
#http = inject(HttpClient) // Handles HTTP requests

/**
* Fetches all tasks from the API.
* Returns an observable containing an array of task strings.
*/
allTasks = () =>
queryOptions({
queryKey: ['tasks'],
queryFn: () => {
return lastValueFrom(this.#http.get<Array<string>>('/api/tasks'));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with modern TypeScript conventions, it's recommended to use the string[] shorthand for an array of strings instead of Array<string>.

Suggested change
return lastValueFrom(this.#http.get<Array<string>>('/api/tasks'));
return lastValueFrom(this.#http.get<string[]>('/api/tasks'));

}
})
}
```

```ts
// tasks.service.spec.ts
import { TestBed } from "@angular/core/testing";
import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http";
import { QueryClient, injectQuery, provideTanStackQuery } from "@tanstack/angular-query-experimental";
import { Injector, inject, runInInjectionContext } from "@angular/core";
import { waitFor } from '@testing-library/angular';
import { mockInterceptor } from "../interceptor/mock-api.interceptor";
import { TasksService } from "./tasks.service";
import type { CreateQueryResult} from "@tanstack/angular-query-experimental";

describe('Test suite: TaskService', () => {
let service!: TasksService;
let injector!: Injector;

// https://angular.dev/guide/http/testing
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withFetch(), withInterceptors([mockInterceptor])),
TasksService,
// It is recommended to cancel the retries in the tests
provideTanStackQuery(new QueryClient({
defaultOptions: {
queries: {
retry: false
}
}
}))
]
});
service = TestBed.inject(TasksService);
injector = TestBed.inject(Injector);
});

it('should get all the Tasks', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The test function it('should get all the Tasks', ...) uses await on line 99, but it is not declared as async. This will result in a syntax error and the test will not run as expected. Test functions that use await must be declared async.

Suggested change
it('should get all the Tasks', () => {
it('should get all the Tasks', async () => {

let allTasks: any;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The allTasks variable is typed as any. Using any defeats the purpose of TypeScript and can hide potential bugs. Since CreateQueryResult is already imported, you should use it to properly type the variable. This will improve type safety and provide better autocompletion.

Suggested change
let allTasks: any;
let allTasks: CreateQueryResult<string[], Error>;

runInInjectionContext(injector, () => {
allTasks = injectQuery(() => service.allTasks());
});
expect(allTasks.status()).toEqual('pending');
Comment on lines +90 to +95
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make the test async to use await

The test uses await but the it callback isn’t async, which will cause a failure. Also fix a typo in the trailing comment.

-it('should get all the Tasks', () => {
+it('should get all the Tasks', async () => {
@@
-  expect(allTasks.data()).toEqual([]); // Considering that the inteceptor is returning [] at the first query request.
+  expect(allTasks.data()).toEqual([]); // Considering that the interceptor returns [] on the first request.

Also applies to: 99-103

🤖 Prompt for AI Agents
In docs/framework/angular/guides/testing.md around lines 90-95 (and also apply
the same change to lines 99-103): the test callback is missing async while it
uses await and there’s a typo in the trailing comment; make the it(...) callback
async, await the call that returns the task result (e.g., await injectQuery(...)
or await runInInjectionContext(...) as appropriate), and correct the trailing
comment typo so the test executes as intended and the comment reads correctly.

expect(allTasks.isFetching()).toEqual(true);
expect(allTasks.data()).toEqual(undefined);
// We await the first result from the query
await waitFor(() => expect(allTasks.isFetching()).toBe(false), {timeout: 10000});
expect(allTasks.status()).toEqual('success');
expect(allTasks.data()).toEqual([]); // Considering that the inteceptor is returning [] at the first query request.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a typo in the comment. "inteceptor" should be "interceptor".

Suggested change
expect(allTasks.data()).toEqual([]); // Considering that the inteceptor is returning [] at the first query request.
expect(allTasks.data()).toEqual([]); // Considering that the interceptor is returning [] at the first query request.

// To have a more complete example have a look at "unit testing / jest"
});
});
```

```ts
// mock-api.interceptor.ts
/**
* MockApiInterceptor is used to simulate API responses for `/api/tasks` endpoints.
* It handles the following operations:
* - GET: Fetches all tasks from sessionStorage.
* - POST: Adds a new task to sessionStorage.
* Simulated responses include a delay to mimic network latency.
*/
import { HttpResponse } from '@angular/common/http'
import { delay, of, throwError } from 'rxjs'
import type {
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http'
import type { Observable } from 'rxjs'

export const mockInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<any>> => {
const respondWith = (status: number, body: any) =>
of(new HttpResponse({ status, body })).pipe(delay(1000))
if (req.url === '/api/tasks') {
switch (req.method) {
case 'GET':
return respondWith(
200,
JSON.parse(
sessionStorage.getItem('unit-testing-tasks') || '[]',
),
)
case 'POST':
const tasks = JSON.parse(
sessionStorage.getItem('unit-testing-tasks') || '[]',
)
tasks.push(req.body)
sessionStorage.setItem(
'unit-testing-tasks',
JSON.stringify(tasks),
)
return respondWith(201, {
status: 'success',
task: req.body,
})
}
}
if (req.url === '/api/tasks-wrong-url') {
return throwError(() => new Error('error')).pipe(delay(1000));
}

return next(req)
}
```

## Turn off retries

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.

## Testing Network Calls

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.
You can see the the Interceptor setup in the "Unit testing / Jest" examples.
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class TasksService {
lastValueFrom(this.#http.post('/api/tasks', task)),
mutationKey: ['tasks'],
onSuccess: () => {
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
}
Expand All @@ -52,7 +52,7 @@ export class TasksService {
mutationFn: () => lastValueFrom(this.#http.delete('/api/tasks')),
mutationKey: ['clearTasks'],
onSuccess: () => {
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
}
Expand Down
2 changes: 1 addition & 1 deletion examples/angular/devtools-panel/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"compileOnSave": false,
"compileOnSave": true,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export class OptimisticUpdatesComponent {
#tasksService = inject(TasksService)

tasks = injectQuery(() => this.#tasksService.allTasks())
clearMutation = injectMutation(() => this.#tasksService.addTask())
addMutation = injectMutation(() => this.#tasksService.addTask())

newItem = ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Simulated responses include a delay to mimic network latency.
*/
import { HttpResponse } from '@angular/common/http'
import { delay, of } from 'rxjs'
import { delay, of, throwError } from 'rxjs'
import type {
HttpEvent,
HttpHandlerFn,
Expand Down Expand Up @@ -46,9 +46,7 @@ export const mockInterceptor: HttpInterceptorFn = (
}
}
if (req.url === '/api/tasks-wrong-url') {
return respondWith(500, {
status: 'error',
})
return throwError(() => new Error('error')).pipe(delay(1000));
}

return next(req)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,8 @@ export class TasksService {
),
),
mutationKey: ['tasks'],
onSuccess: () => {
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
onMutate: async ({ task }) => {
onSuccess: () => {},
onMutate: async ({ task } : {task: string}) => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await this.#queryClient.cancelQueries({ queryKey: ['tasks'] })
Expand All @@ -70,14 +68,14 @@ export class TasksService {

return previousTodos
},
onError: (err, variables, context) => {
onError: (_err: any, _variables: any, context: any) => {
if (context) {
this.#queryClient.setQueryData<Array<string>>(['tasks'], context)
}
},
// Always refetch after error or success:
onSettled: () => {
this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
}
Expand Down
4 changes: 4 additions & 0 deletions examples/angular/unit-testing/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Node.js",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22"
}
6 changes: 6 additions & 0 deletions examples/angular/unit-testing/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {}

module.exports = config
7 changes: 7 additions & 0 deletions examples/angular/unit-testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# TanStack Query Angular unit-testing example

To run this example:

- `npm install` or `yarn` or `pnpm i` or `bun i`
- `npm run start` or `yarn start` or `pnpm start` or `bun start`
- `npm run test` to run the tests
Loading
Loading