diff --git a/docs/config.json b/docs/config.json index 31182f860b..f5324984ee 100644 --- a/docs/config.json +++ b/docs/config.json @@ -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" diff --git a/docs/framework/angular/guides/testing.md b/docs/framework/angular/guides/testing.md new file mode 100644 index 0000000000..7648d7f6b3 --- /dev/null +++ b/docs/framework/angular/guides/testing.md @@ -0,0 +1,157 @@ +--- +id: testing +title: Testing +--- + +Most Angular tests using TanStack Query will involve services or components that call `injectQuery`/`injectMutation`. + +TanStack Query's `inject*` functions integrate with [`PendingTasks`](https://angular.dev/api/core/PendingTasks) which ensures the framework is aware of in-progress queries and mutations. + +This means tests and SSR can wait until mutations and queries resolve. In unit tests you can use `ApplicationRef.whenStable()` or `fixture.whenStable()` to await query completion. This works for both Zone.js and Zoneless setups. + +> This integration requires Angular 19 or later. Earlier versions of Angular do not support `PendingTasks`. + +## TestBed setup + +Create a fresh `QueryClient` for every spec and provide it with `provideTanStackQuery` or `provideQueryClient`. This keeps caches isolated and lets you change default options per test: + +```ts +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, // ✅ faster failure tests + }, + }, +}) + +TestBed.configureTestingModule({ + providers: [provideTanStackQuery(queryClient)], +}) +``` + +> If your applications actual TanStack Query config is used in unit tests, make sure `withDevtools` is not accidentally included in test providers. This can cause slow tests. It is best to keep test and production configs separate. + +If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. + +## First query test + +Query tests typically run inside `TestBed.runInInjectionContext`, then wait for stability: + +```ts +const appRef = TestBed.inject(ApplicationRef) +const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: ['greeting'], + queryFn: () => 'Hello', + })), +) + +TestBed.tick() // Trigger effect + +// Application is stable when queries are idle +await appRef.whenStable() + +expect(query.status()).toBe('success') +expect(query.data()).toBe('Hello') +``` + +PendingTasks will have `whenStable()` resolve after the query settles. When using fake timers (Vitest), advance the clock and a microtask before awaiting stability: + +```ts +await vi.advanceTimersByTimeAsync(0) +await Promise.resolve() +await appRef.whenStable() +``` + +## Testing components + +For components, bootstrap them through `TestBed.createComponent`, then await `fixture.whenStable()`: + +```ts +const fixture = TestBed.createComponent(ExampleComponent) + +await fixture.whenStable() +expect(fixture.componentInstance.query.data()).toEqual({ value: 42 }) +``` + +## Handling retries + +Retries slow failing tests because the default backoff runs three times. Set `retry: false` (or a specific number) through `defaultOptions` or per query to keep tests fast. If a query intentionally retries, assert on the final state rather than intermediate counts. + +## HttpClient & network stubs + +Angular's `HttpClientTestingModule` plays nicely with PendingTasks. Register it alongside the Query provider and flush responses through `HttpTestingController`: + +```ts +TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [provideTanStackQuery(queryClient)], +}) + +const httpCtrl = TestBed.inject(HttpTestingController) +const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: ['todos'], + queryFn: () => lastValueFrom(TestBed.inject(HttpClient).get('/api/todos')), + })), +) + +const fixturePromise = TestBed.inject(ApplicationRef).whenStable() +httpCtrl.expectOne('/api/todos').flush([{ id: 1 }]) +await fixturePromise + +expect(query.data()).toEqual([{ id: 1 }]) +httpCtrl.verify() +``` + +## Infinite queries & pagination + +Use the same pattern for infinite queries: call `fetchNextPage()`, advance timers if you are faking time, then await stability and assert on `data().pages`. + +```ts +const infinite = TestBed.runInInjectionContext(() => + injectInfiniteQuery(() => ({ + queryKey: ['pages'], + queryFn: ({ pageParam = 1 }) => fetchPage(pageParam), + getNextPageParam: (last, all) => all.length + 1, + })), +) + +await appRef.whenStable() +expect(infinite.data().pages).toHaveLength(1) + +await infinite.fetchNextPage() +await vi.advanceTimersByTimeAsync(0) +await appRef.whenStable() + +expect(infinite.data().pages).toHaveLength(2) +``` + +## Mutations and optimistic updates + +```ts +const mutation = TestBed.runInInjectionContext(() => + injectMutation(() => ({ + mutationFn: async (input: string) => input.toUpperCase(), + })), +) + +mutation.mutate('test') + +// Trigger effect +TestBed.tick() + +await appRef.whenStable() + +expect(mutation.isSuccess()).toBe(true) +expect(mutation.data()).toBe('TEST') +``` + +## Quick checklist + +- Fresh `QueryClient` per test (and clear it afterwards) +- Disable or control retries to avoid timeouts +- Advance timers + microtasks before `whenStable()` when using fake timers +- Use `HttpClientTestingModule` or your preferred mock to assert network calls +- Await `whenStable()` after every `refetch`, `fetchNextPage`, or mutation +- Prefer `TestBed.runInInjectionContext` for service tests and `fixture.whenStable()` for component tests