Skip to content
Closed
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"
}
Comment on lines +1292 to 1294
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

Angular "Unit Testing / Jest" example — looks good; verify example page and code exist

Great addition. Let’s ensure both the docs page and the example project are present.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking for Angular Unit Testing example doc page..."
fd -a -i --type f 'unit-testing\.(md|mdx|tsx|jsx)' 2>/dev/null | rg -n 'framework/angular/examples' || {
  echo "Missing doc page for slug framework/angular/examples/unit-testing"
  exit 1
}

echo "Checking example project folder and spec file..."
fd -a -i 'examples/angular/unit-testing' >/dev/null
fd -a -i 'examples/angular/unit-testing/src/app/services/tasks.service.spec.ts' >/dev/null

echo "OK: Example page and project detected."

Length of output: 374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Listing all docs files and directories with 'unit-testing' in their paths under docs/"
fd -ai 'unit-testing' docs

echo ""
echo "Verifying example project folder:"
if fd -t d 'unit-testing' examples/angular; then
  echo "✔ Example directory found: examples/angular/unit-testing"
else
  echo "✖ Missing examples/angular/unit-testing directory"
fi

echo ""
echo "Verifying spec file exists:"
if [[ -f "examples/angular/unit-testing/src/app/services/tasks.service.spec.ts" ]]; then
  echo "✔ Spec file found"
else
  echo "✖ Missing spec: examples/angular/unit-testing/src/app/services/tasks.service.spec.ts"
fi

Length of output: 716


Missing Angular “Unit Testing / Jest” docs page

Our verification shows:

  • ✔ Example project directory exists at examples/angular/unit-testing/
  • ✔ Spec file present at examples/angular/unit-testing/src/app/services/tasks.service.spec.ts
  • ✖ No documentation page found under docs/framework/angular/examples/unit-testing.*

Please add the corresponding docs page (e.g. docs/framework/angular/examples/unit-testing.md or .mdx) so that the config.json entry under "framework/angular/examples/unit-testing" points to a real file.

🤖 Prompt for AI Agents
In docs/config.json around lines 1292 to 1294, the entry for "Unit Testing /
Jest" under "framework/angular/examples/unit-testing" points to a documentation
page that does not exist. To fix this, create a new documentation file named
unit-testing.md or unit-testing.mdx in the docs/framework/angular/examples/
directory that covers the Angular unit testing example, ensuring the config.json
link points to this new file.

]
}
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', () => {

Choose a reason for hiding this comment

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

critical

The test function is missing the async keyword. Since await is used on line 99, this will result in a syntax error. Please add async to the function definition to make it a valid asynchronous test.

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.

medium

Using any for allTasks is not ideal, especially in documentation that should promote best practices. The type CreateQueryResult is already imported on line 64 and can be used here to provide strong typing.

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

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.

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"
});
Comment on lines +90 to +103
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 allow await (copy-paste fix).

You’re using await inside the test body, but it(...) isn’t marked async.

-    it('should get all the Tasks', () => {
+    it('should get all the tasks', async () => {
📝 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
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"
});
it('should get all the tasks', async () => {
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"
});
🤖 Prompt for AI Agents
In docs/framework/angular/guides/testing.md around lines 90 to 103, the test
function uses `await` but the `it` callback is not marked as `async`. To fix
this, add the `async` keyword before the callback function in the `it` block
declaration so that `await` can be used properly inside the test.

});
```

```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'] })
},
Comment on lines +55 to 56
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Same here: await invalidation for clear-all.

Aligns semantics with addTask and ensures tests observe a refetched cache.

-      onSuccess: () => {
-        return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
-      },
+      onSuccess: async () => {
+        await this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
+      },
📝 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
return this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
onSuccess: async () => {
await this.#queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
🤖 Prompt for AI Agents
In examples/angular/auto-refetching/src/app/services/tasks.service.ts around
lines 55 to 56, the call to invalidateQueries is not awaited, which can cause
inconsistent cache state observation in tests. Fix this by adding the await
keyword before this.#queryClient.invalidateQueries to ensure the invalidation
completes before proceeding, aligning with the addTask method's behavior.

})
}
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'
Comment on lines 8 to +9
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

Add missing HttpErrorResponse import (and keep style consistent).

To support the change above, import HttpErrorResponse alongside HttpResponse. Also consider removing the lone semicolon at Line 49 to match the file’s no-semi style.

-import { HttpResponse } from '@angular/common/http'
+import { HttpResponse, HttpErrorResponse } from '@angular/common/http'

Search for code/tests expecting HttpErrorResponse shape (status, message) to confirm this aligns with consumers:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Scanning for patterns that assume HttpErrorResponse shape..."
rg -n --glob 'examples/angular/**' \
  'HttpErrorResponse|error\.status|instanceof\s+HttpErrorResponse|catchError\(' -A 3

Length of output: 270


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and display the mock API interceptor file around the relevant import and line 49
file=$(fd --type f mock-api.interceptor.ts | head -n1)
echo "File: $file"
echo "----- Lines 1-60 -----"
sed -n '1,60p' "$file"
echo "----- Lines 40-80 -----"
sed -n '40,80p' "$file"

Length of output: 2219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Display the import statements and around line 49 for the optimistic-updates example
file="examples/angular/optimistic-updates/src/app/interceptor/mock-api.interceptor.ts"
echo "File: $file"
echo "----- Lines 1-20 (imports) -----"
sed -n '1,20p' "$file"
echo "----- Lines 40-60 -----"
sed -n '40,60p' "$file"

Length of output: 1543


Remove the unnecessary import suggestion and fix the trailing semicolon

The file doesn’t reference HttpErrorResponse anywhere, so you can skip adding that import. However, there’s a lone semicolon on the throwError return that should be removed to match the file’s no-semi style.

• File: examples/angular/optimistic-updates/src/app/interceptor/mock-api.interceptor.ts
• Around line ~49:

-return throwError(() => new Error('error')).pipe(delay(1000));
+return throwError(() => new Error('error')).pipe(delay(1000))
📝 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
import { HttpResponse } from '@angular/common/http'
import { delay, of } from 'rxjs'
import { delay, of, throwError } from 'rxjs'
return throwError(() => new Error('error')).pipe(delay(1000))
🤖 Prompt for AI Agents
In
examples/angular/optimistic-updates/src/app/interceptor/mock-api.interceptor.ts
around lines 8 to 9 and near line 49, remove the unnecessary import of
HttpErrorResponse since it is not used in the file. Also, locate the throwError
return statement around line 49 and remove the trailing semicolon to comply with
the file's no-semicolon style.

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.

🛠️ Refactor suggestion

Emit HttpErrorResponse instead of a generic Error for realistic HttpClient errors and stronger typing.

Returning a plain Error loses status/URL/context that many handlers and tests depend on (e.g., checking error.status). Prefer HttpErrorResponse to accurately simulate backend failures.

-    return throwError(() => new Error('error')).pipe(delay(1000));
+    return throwError(() => new HttpErrorResponse({
+      status: 500,
+      statusText: 'Server Error',
+      url: req.url,
+      error: { status: 'error' },
+    })).pipe(delay(1000))

Note: This change requires importing HttpErrorResponse (see next comment).

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
examples/angular/optimistic-updates/src/app/interceptor/mock-api.interceptor.ts
at line 49, replace the generic Error thrown by throwError with an instance of
HttpErrorResponse to simulate realistic HttpClient errors. This change preserves
HTTP status, URL, and context information that downstream handlers and tests
rely on. Import HttpErrorResponse from @angular/common/http and construct it
with appropriate properties such as status and statusText before passing it to
throwError.

}

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
Comment on lines +5 to +7
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

Include equivalent test commands for all package managers.

Right now only the npm test command is documented. Add yarn/pnpm/bun equivalents for consistency with the install/start bullets.

- - `npm run test` to run the tests
+ - `npm run test` or `yarn test` or `pnpm test` or `bun test` to run the tests

Quick check to ensure start and test scripts exist in the example’s package.json:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

PKG="examples/angular/unit-testing/package.json"
if [[ ! -f "$PKG" ]]; then
  echo "Not found: $PKG"
  exit 1
fi

echo "Scripts in $PKG:"
jq -r '.scripts' "$PKG"

echo
echo "Expecting both \"start\" and \"test\" script entries..."
jq -er '.scripts.start' "$PKG" >/dev/null && echo "start: OK" || { echo "start: MISSING"; exit 1; }
jq -er '.scripts.test'  "$PKG" >/dev/null && echo "test:  OK" || { echo "test:  MISSING"; exit 1; }

Length of output: 758


Add missing test commands for Yarn, pnpm, and Bun in README

The test script is defined in examples/angular/unit-testing/package.json, so we should list all package‐manager equivalents for consistency with the install/start steps.

• File: examples/angular/unit-testing/README.md
• Lines: 5–7

- - `npm run test` to run the tests
+ - `npm run test` or `yarn test` or `pnpm test` or `bun test` to run the tests
📝 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
- `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
- `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` or `yarn test` or `pnpm test` or `bun test` to run the tests
🤖 Prompt for AI Agents
In examples/angular/unit-testing/README.md around lines 5 to 7, the test command
is only listed for npm. Add the equivalent test commands for Yarn, pnpm, and Bun
to match the style used for install and start commands. Update the third line to
include "yarn test", "pnpm test", and "bun test" alongside "npm run test" for
consistency.

Loading
Loading