Angular 19 Resource API and Linked Signals Testing Guide
Angular 19 introduced two major reactive primitives: resource() for async data fetching tied to signals, and linkedSignal() for derived writable state. Both are in developer preview as of Angular 19.0. Testing them requires understanding their async lifecycle — resources go through idle, loading, loaded, error, and local states — and how to mock fetch functions in tests.
Key Takeaways
resource() is a signal-based async data fetcher. It takes a request signal and a loader function. When request() changes, the loader re-runs automatically. The resource exposes value(), status(), error(), and isLoading() signals.
Mock the loader function in tests. Don't mock HttpClient directly — pass a fake async function to resource({ loader: () => Promise.resolve(mockData) }). This isolates the resource from HTTP infrastructure.
Use TestBed.flushEffects() after mutations. Resource state changes happen asynchronously. Flush effects before asserting on status() or value().
linkedSignal() is a writable computed. It derives its value from source signals but can be overwritten by the user. Test both the derived value and manual overrides.
Status values are named constants. Check ResourceStatus.Loading, ResourceStatus.Loaded, ResourceStatus.Error — don't compare against magic strings.
Angular 19 Resource API Overview
The resource() API replaces the common pattern of signal + effect + HttpClient:
// Before Angular 19 (verbose)
const userId = signal(1);
const user = signal<User | null>(null);
const loading = signal(false);
const error = signal<Error | null>(null);
effect(async () => {
loading.set(true);
try {
user.set(await fetchUser(userId()));
} catch (e) {
error.set(e as Error);
} finally {
loading.set(false);
}
});
// Angular 19 (declarative)
const userId = signal(1);
const userResource = resource({
request: userId,
loader: ({ request: id }) => fetchUser(id)
});
// userResource.value(), userResource.isLoading(), userResource.error(), userResource.status()Testing a Resource Directly
// user-resource.ts
import { resource, signal } from '@angular/core';
export function createUserResource(fetchFn: (id: number) => Promise<User>) {
const userId = signal(1);
const userResource = resource({
request: userId,
loader: ({ request: id }) => fetchFn(id)
});
return { userId, userResource };
}import { TestBed, fakeAsync } from '@angular/core/testing';
import { ResourceStatus } from '@angular/core';
import { createUserResource } from './user-resource';
describe('user resource', () => {
it('starts in loading state', fakeAsync(async () => {
const mockFetch = jasmine.createSpy('fetchUser').and.returnValue(
new Promise(() => {}) // never resolves
);
TestBed.runInInjectionContext(() => {
const { userResource } = createUserResource(mockFetch);
TestBed.flushEffects();
expect(userResource.status()).toBe(ResourceStatus.Loading);
expect(userResource.isLoading()).toBeTrue();
expect(userResource.value()).toBeUndefined();
});
}));
it('transitions to loaded state with data', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
const mockFetch = () => Promise.resolve(mockUser);
await TestBed.runInInjectionContext(async () => {
const { userResource } = createUserResource(mockFetch);
TestBed.flushEffects();
await userResource.value.toPromise?.() ?? new Promise(r => setTimeout(r, 0));
// Wait for the resource to resolve
await new Promise(r => setTimeout(r, 0));
TestBed.flushEffects();
expect(userResource.status()).toBe(ResourceStatus.Loaded);
expect(userResource.value()).toEqual(mockUser);
expect(userResource.isLoading()).toBeFalse();
});
});
it('transitions to error state on failure', async () => {
const mockError = new Error('Network error');
const mockFetch = () => Promise.reject(mockError);
await TestBed.runInInjectionContext(async () => {
const { userResource } = createUserResource(mockFetch);
TestBed.flushEffects();
await new Promise(r => setTimeout(r, 0));
TestBed.flushEffects();
expect(userResource.status()).toBe(ResourceStatus.Error);
expect(userResource.error()).toBe(mockError);
expect(userResource.value()).toBeUndefined();
});
});
it('reloads when request signal changes', async () => {
const fetchCalls: number[] = [];
const mockFetch = (id: number) => {
fetchCalls.push(id);
return Promise.resolve({ id, name: `User ${id}`, email: '' });
};
await TestBed.runInInjectionContext(async () => {
const { userId, userResource } = createUserResource(mockFetch);
TestBed.flushEffects();
await new Promise(r => setTimeout(r, 0));
userId.set(2); // trigger reload
TestBed.flushEffects();
await new Promise(r => setTimeout(r, 0));
TestBed.flushEffects();
expect(fetchCalls).toContain(1);
expect(fetchCalls).toContain(2);
expect(userResource.value()?.id).toBe(2);
});
});
});Testing Resource-Based Components
// user-profile.component.ts
import { Component, input, resource } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
@if (userResource.isLoading()) {
<div data-testid="loading">Loading...</div>
} @else if (userResource.status() === 'error') {
<div data-testid="error">{{ userResource.error()?.message }}</div>
} @else if (userResource.value()) {
<div data-testid="profile">
<h1>{{ userResource.value()!.name }}</h1>
<p>{{ userResource.value()!.email }}</p>
</div>
}
`
})
export class UserProfileComponent {
userId = input.required<number>();
userResource = resource({
request: this.userId,
loader: ({ request: id }) =>
firstValueFrom(this.http.get<any>(`/api/users/${id}`))
});
constructor(private http: HttpClient) {}
}import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { UserProfileComponent } from './user-profile.component';
describe('UserProfileComponent with resource', () => {
let fixture: ComponentFixture<UserProfileComponent>;
let controller: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserProfileComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
}).compileComponents();
fixture = TestBed.createComponent(UserProfileComponent);
controller = TestBed.inject(HttpTestingController);
fixture.componentRef.setInput('userId', 1);
fixture.detectChanges();
});
afterEach(() => controller.verify());
it('shows loading while request is pending', () => {
expect(fixture.nativeElement.querySelector('[data-testid="loading"]')).toBeTruthy();
controller.expectOne('/api/users/1').flush({});
});
it('displays user profile after load', async () => {
const req = controller.expectOne('/api/users/1');
req.flush({ id: 1, name: 'Alice', email: 'alice@example.com' });
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="profile"]')).toBeTruthy();
expect(fixture.nativeElement.querySelector('h1').textContent).toBe('Alice');
});
it('shows error when request fails', async () => {
const req = controller.expectOne('/api/users/1');
req.flush('Not found', { status: 404, statusText: 'Not Found' });
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="error"]')).toBeTruthy();
});
it('reloads when userId input changes', async () => {
controller.expectOne('/api/users/1').flush({ id: 1, name: 'Alice', email: '' });
await fixture.whenStable();
fixture.detectChanges();
// Change the userId input
fixture.componentRef.setInput('userId', 2);
fixture.detectChanges();
const req = controller.expectOne('/api/users/2');
req.flush({ id: 2, name: 'Bob', email: 'bob@example.com' });
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h1').textContent).toBe('Bob');
});
});Testing linkedSignal()
linkedSignal() creates a writable signal whose value is derived from another signal but can be overridden by the user.
// sort.component.ts
import { Component, input, linkedSignal } from '@angular/core';
@Component({
selector: 'app-sort',
standalone: true,
template: `
<select (change)="onSortChange($event)" [value]="sortField()">
<option value="name">Name</option>
<option value="email">Email</option>
<option value="createdAt">Created At</option>
</select>
<p data-testid="current-sort">{{ sortField() }}</p>
`
})
export class SortComponent {
defaultSort = input<string>('name');
// Derives from input, but user can override via select
sortField = linkedSignal(() => this.defaultSort());
onSortChange(event: Event) {
this.sortField.set((event.target as HTMLSelectElement).value);
}
}import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SortComponent } from './sort.component';
describe('SortComponent linkedSignal', () => {
let fixture: ComponentFixture<SortComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SortComponent]
}).compileComponents();
fixture = TestBed.createComponent(SortComponent);
fixture.componentRef.setInput('defaultSort', 'name');
fixture.detectChanges();
});
it('initializes from the input signal', () => {
expect(fixture.nativeElement.querySelector('[data-testid="current-sort"]').textContent).toBe('name');
});
it('allows user override via select', () => {
const select = fixture.nativeElement.querySelector('select');
select.value = 'email';
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="current-sort"]').textContent).toBe('email');
});
it('resets to new input value when input changes', () => {
// User overrides
const select = fixture.nativeElement.querySelector('select');
select.value = 'email';
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
expect(fixture.componentInstance.sortField()).toBe('email');
// Input changes — linkedSignal resets
fixture.componentRef.setInput('defaultSort', 'createdAt');
fixture.detectChanges();
expect(fixture.componentInstance.sortField()).toBe('createdAt');
expect(fixture.nativeElement.querySelector('[data-testid="current-sort"]').textContent).toBe('createdAt');
});
it('directly setting signal works', () => {
fixture.componentInstance.sortField.set('email');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="current-sort"]').textContent).toBe('email');
});
});Testing linkedSignal() in Pure Unit Tests
import { linkedSignal, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
describe('linkedSignal', () => {
it('derives initial value from source', () => {
TestBed.runInInjectionContext(() => {
const source = signal('initial');
const linked = linkedSignal(() => source());
expect(linked()).toBe('initial');
});
});
it('updates when source changes', () => {
TestBed.runInInjectionContext(() => {
const source = signal('a');
const linked = linkedSignal(() => source());
source.set('b');
expect(linked()).toBe('b');
});
});
it('can be overridden by manual set', () => {
TestBed.runInInjectionContext(() => {
const source = signal('a');
const linked = linkedSignal(() => source());
linked.set('override');
expect(linked()).toBe('override');
// Source change resets the override
source.set('c');
expect(linked()).toBe('c');
});
});
it('update() works the same as set()', () => {
TestBed.runInInjectionContext(() => {
const source = signal(10);
const linked = linkedSignal(() => source() * 2);
linked.update(v => v + 1); // 20 + 1 = 21
expect(linked()).toBe(21);
});
});
});Resource Manual Refresh
Angular 19 resources support manual refresh via resource.reload():
it('reloads when reload() is called', async () => {
let callCount = 0;
const loader = () => {
callCount++;
return Promise.resolve({ count: callCount });
};
await TestBed.runInInjectionContext(async () => {
const r = resource({ loader });
TestBed.flushEffects();
await new Promise(resolve => setTimeout(resolve, 0));
TestBed.flushEffects();
expect(callCount).toBe(1);
expect(r.value()?.count).toBe(1);
r.reload();
TestBed.flushEffects();
await new Promise(resolve => setTimeout(resolve, 0));
TestBed.flushEffects();
expect(callCount).toBe(2);
expect(r.value()?.count).toBe(2);
});
});Common Pitfalls
Forgetting injection context Both resource() and linkedSignal() must be created inside an injection context (component constructor, field initializer, or TestBed.runInInjectionContext()). Creating them at the top level of a test file throws NG0203.
Not flushing effects before asserting Resource state transitions are asynchronous. Always call TestBed.flushEffects() and await new Promise(r => setTimeout(r, 0)) to let microtasks settle before checking status() or value().
Comparing status to strings Use ResourceStatus.Loaded etc. from @angular/core — the status values are typed constants, not raw strings.
linkedSignal reset on source change When the source signal changes, linkedSignal resets to the derived value, discarding any manual override. This is by design but can surprise you in tests if you set then assert on input changes.
Summary
Angular 19 resource() and linkedSignal() are powerful primitives that simplify reactive data flows:
- Mock the
loaderfunction to test resource state transitions without HTTP infrastructure - Flush effects and drain microtasks before asserting on resource status
- Use
ResourceStatus.*constants for status comparisons linkedSignal()resets to derived value when source changes — test both override and reset scenarios- Components using
resource()can still be tested withHttpTestingController— the resource's loader callsHttpClientinternally