-
Notifications
You must be signed in to change notification settings - Fork 0
feat(docs): add the angular testing documentation #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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')); | ||||||||||||||||||||||
arvi18 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```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', () => { | ||||||||||||||||||||||
arvi18 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||
| let allTasks: any; | ||||||||||||||||||||||
arvi18 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||
| runInInjectionContext(injector, () => { | ||||||||||||||||||||||
| allTasks = injectQuery(() => service.allTasks()); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| expect(allTasks.status()).toEqual('pending'); | ||||||||||||||||||||||
arvi18 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||
| 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. | ||||||||||||||||||||||
arvi18 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🧰 Tools🪛 LanguageTool[grammar] ~170-~170: There might be a mistake here. (QB_NEW_EN) [grammar] ~171-~171: There might be a mistake here. (QB_NEW_EN) [grammar] ~171-~171: There might be a mistake here. (QB_NEW_EN) 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent Error HandlingThe 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
|
||
| } | ||
|
|
||
| return next(req) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,10 +47,8 @@ export class TasksService { | |
| ), | ||
| ), | ||
| mutationKey: ['tasks'], | ||
| onSuccess: () => { | ||
| this.#queryClient.invalidateQueries({ queryKey: ['tasks'] }) | ||
| }, | ||
| onMutate: async ({ task }) => { | ||
| onSuccess: () => {}, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent Query InvalidationThe onSuccess handler no longer invalidates queries unlike other examples. This inconsistency could cause stale data to persist in the cache, affecting functional correctness. Standards
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent Return PatternEmpty 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
|
||
| onMutate: async ({ task } : {task: string}) => { | ||
| // Cancel any outgoing refetches | ||
| // (so they don't overwrite our optimistic update) | ||
| await this.#queryClient.cancelQueries({ queryKey: ['tasks'] }) | ||
|
|
@@ -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'] }) | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| 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" | ||
| } |
| 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 |
| 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 |
There was a problem hiding this comment.
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:
Please confirm the corresponding pages exist and routes render in the docs site.
Run:
Also applies to: 1290-1294
🏁 Script executed:
Length of output: 364
Missing example route directory
The path
docs/framework/angular/examples/unit-testingdoes not exist, so its nav link indocs/config.json(lines 710–713 and 1290–1294) will 404. Add that directory with anindex.*file or correct the config path.🤖 Prompt for AI Agents