Testing Angular Signals — State, Effects and Computed Values

📋 Table of Contents
  1. Signal Testing Patterns
  2. Common Mistakes

Angular’s Signal system is reactive by design — a computed value automatically updates when its source signals change. In tests, this reactivity works correctly but requires fixture.detectChanges() to propagate signal changes to the template. The key testing insight: test the computed behaviour (what the template shows after a signal change) rather than the signal’s internal value. This aligns tests with what matters: the user-visible rendering.

Signal Testing Patterns

// ── Component under test ──────────────────────────────────────────────────
@Component({
  selector: 'app-post-list',
  standalone: true,
  template: `
    @if (loading()) {
      <div data-cy="spinner">Loading...</div>
    } @else if (error()) {
      <div data-cy="error">{{ error() }}</div>
    } @else {
      @for (post of posts(); track post.id) {
        <div data-cy="post-item">{{ post.title }}</div>
      } @empty {
        <div data-cy="empty-state">No posts</div>
      }
    }
    <span data-cy="post-count">{{ postCount() }}</span>
  `,
})
export class PostListComponent {
  posts    = signal<PostSummaryDto[]>([]);
  loading  = signal(true);
  error    = signal<string | null>(null);
  postCount = computed(() => this.posts().length);
}

// ── Signal tests ──────────────────────────────────────────────────────────
describe('PostListComponent signal-based rendering', () => {
  let fixture:   ComponentFixture<PostListComponent>;
  let component: PostListComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [PostListComponent],
    }).compileComponents();
    fixture   = TestBed.createComponent(PostListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('shows spinner when loading is true', () => {
    component.loading.set(true);
    fixture.detectChanges();
    expect(fixture.nativeElement.querySelector('[data-cy="spinner"]'))
      .not.toBeNull();
  });

  it('hides spinner and shows posts when loading completes', () => {
    component.posts.set([
      { id: 1, title: 'Post A', slug: 'post-a' },
      { id: 2, title: 'Post B', slug: 'post-b' },
    ] as PostSummaryDto[]);
    component.loading.set(false);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('[data-cy="spinner"]')).toBeNull();
    const items = fixture.nativeElement.querySelectorAll('[data-cy="post-item"]');
    expect(items.length).toBe(2);
    expect(items[0].textContent).toContain('Post A');
  });

  it('shows empty state when posts array is empty', () => {
    component.posts.set([]);
    component.loading.set(false);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('[data-cy="empty-state"]'))
      .not.toBeNull();
  });

  it('updates computed postCount when posts signal changes', () => {
    component.posts.set([{ id: 1 } as PostSummaryDto]);
    fixture.detectChanges();

    const countEl = fixture.nativeElement.querySelector('[data-cy="post-count"]');
    expect(countEl.textContent).toContain('1');

    component.posts.update(p => [...p, { id: 2 } as PostSummaryDto]);
    fixture.detectChanges();

    expect(countEl.textContent).toContain('2');
  });

  it('shows error message when error signal is set', () => {
    component.loading.set(false);
    component.error.set('Failed to load posts. Please try again.');
    fixture.detectChanges();

    const errorEl = fixture.nativeElement.querySelector('[data-cy="error"]');
    expect(errorEl).not.toBeNull();
    expect(errorEl.textContent).toContain('Failed to load posts');
  });
});
Note: Signal mutations in tests (component.posts.set([...])) are synchronous — the signal value changes immediately. However, the DOM does not update until fixture.detectChanges() is called. This two-step process (mutate signal → detect changes → assert on DOM) is how Angular TestBed works for all change detection strategies. In production code running in a browser with zone.js, signal mutations trigger automatic change detection — but TestBed requires explicit triggering to keep tests deterministic.
Tip: Test computed signals by asserting on their effect (the DOM), not their value directly. expect(component.postCount()).toBe(2) tests the signal’s value but doesn’t verify that the template uses it correctly. expect(countEl.textContent).toContain('2') tests that the computed value is correctly bound and rendered — which is what actually matters for the user. The signal value test would pass even if the template had a typo in the binding.
Warning: Effects (effect(() => ...)) run asynchronously in Angular’s effect scheduling. In tests, pending effects may not have run when you make an assertion. In Angular 18+, use TestBed.flushEffects() to synchronously flush all pending effects before asserting. Without this, tests that depend on effect side effects can produce intermittent failures when the effect hasn’t fired yet. For the BlogApp’s logging effect or title-update effect, this distinction matters.

Common Mistakes

Mistake 1 — Testing signal value directly instead of template effect (misses binding bugs)

❌ Wrong — expect(component.postCount()).toBe(2); signal is correct but template binding is broken — test still passes.

✅ Correct — assert on what the user sees in the DOM; signal tests through the template binding are more meaningful.

Mistake 2 — Forgetting detectChanges after signal mutation (stale DOM)

❌ Wrong — component.loading.set(false); expect(spinner).toBeNull() — spinner still in DOM; detectChanges not called.

✅ Correct — always fixture.detectChanges() after any signal mutation before DOM assertions.

🧠 Test Yourself

A component has total = computed(() => this.items().length * this.price()). A test sets items.set([1,2,3]) then asserts on total(). Does fixture.detectChanges() need to be called for the computed to update?