Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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"
},
Comment on lines +710 to +713
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Nav additions look correct; verify targets resolve at build time.

The new Angular “Testing” guide and “Unit Testing / Jest” example entries point to:

  • framework/angular/guides/testing
  • framework/angular/examples/unit-testing

Please confirm the corresponding pages exist and routes render in the docs site.

Run:

Also applies to: 1290-1294


🏁 Script executed:

#!/bin/bash
# Verify guide page exists
fd -t f -a 'testing.md' docs/framework/angular/guides -x echo FOUND {} || true

# Verify example route directory (index.mdx/md/tsx)
fd -t f -a 'index.*' docs/framework/angular/examples/unit-testing -x echo FOUND {} || true

Length of output: 364


Missing example route directory
The path docs/framework/angular/examples/unit-testing does not exist, so its nav link in docs/config.json (lines 710–713 and 1290–1294) will 404. Add that directory with an index.* file or correct the config path.

🤖 Prompt for AI Agents
In docs/config.json around lines 710–713 (and also update the duplicate at
1290–1294), the nav entry points to docs/framework/angular/examples/unit-testing
which does not exist and will 404; either create the missing directory
docs/framework/angular/examples/unit-testing and add an index file (e.g.,
index.md or index.mdx) with the example content, or change both config.json
entries to point to the correct existing path for the Angular testing example so
the links resolve.

{
"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'));
}
})
}
```

```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', () => {
let allTasks: any;
runInInjectionContext(injector, () => {
allTasks = injectQuery(() => service.allTasks());
});
expect(allTasks.status()).toEqual('pending');
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.
// 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.
Comment on lines +166 to +171
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix typos and grammar in user-facing docs

“These are visible to readers; let’s polish.”

-## 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.
+## Testing network calls
+
+Instead of targeting a server, mock the requests. There are multiple ways to do this; we recommend using an Angular HTTP interceptor (see the [docs](https://angular.dev/guide/http/interceptors) for details).
+You can see the interceptor setup in the “Unit testing / Jest” examples.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
## Testing network calls
Instead of targeting a server, mock the requests. There are multiple ways to do this; we recommend using an Angular HTTP interceptor (see the [docs](https://angular.dev/guide/http/interceptors) for details).
You can see the interceptor setup in the “Unit testing / Jest” examples.
🧰 Tools
🪛 LanguageTool

[grammar] ~170-~170: There might be a mistake here.
Context: ...ide/http/interceptors) for more details. You can see the the Interceptor setup in...

(QB_NEW_EN)


[grammar] ~171-~171: There might be a mistake here.
Context: ...tors) for more details. You can see the the Interceptor setup in the "Unit testing ...

(QB_NEW_EN)


[grammar] ~171-~171: There might be a mistake here.
Context: ...p in the "Unit testing / Jest" examples.

(QB_NEW_EN)

🤖 Prompt for AI Agents
In docs/framework/angular/guides/testing.md around lines 166 to 171, fix typos
and improve grammar: change "turn retries off" to "turn off retries" (or
"disable retries"), correct "targetting" to "targeting", change "multiple way"
to "multiple ways", remove the duplicated "the the" so it reads "You can see the
Interceptor setup...", and normalize capitalization/usage of "Interceptor" (use
lowercase "interceptor" unless it's a proper noun). Apply these edits inline to
the paragraph for a clearer, grammatical user-facing doc.

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
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));
Copy link

Choose a reason for hiding this comment

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

Inconsistent Error Handling

The error handling approach was changed from returning an HTTP error response to throwing a JavaScript Error. This creates inconsistency with how real HTTP errors would behave in production, potentially leading to incorrect error handling logic in components consuming this service.

Standards
  • Algorithm-Correctness-Error-Handling
  • Business-Rule-Exception-Management
  • Logic-Verification-API-Consistency

}

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: () => {},
Copy link

Choose a reason for hiding this comment

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

Inconsistent Query Invalidation

The onSuccess handler no longer invalidates queries unlike other examples. This inconsistency could cause stale data to persist in the cache, affecting functional correctness.

Standards
  • ISO-IEC-25010-Functional-Correctness-Appropriateness
  • ISO-IEC-25010-Reliability-Maturity

Copy link

Choose a reason for hiding this comment

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

Inconsistent Return Pattern

Empty onSuccess callback differs from other services that return invalidateQueries. Inconsistent return patterns across similar methods create maintenance confusion when developers need to understand callback behavior.

Standards
  • Clean-Code-Consistency
  • Design-Pattern-Observer

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