TestBed Component Testing — Rendering, Inputs and Outputs

Testing Angular components with TestBed validates that templates render correctly, inputs are consumed properly, and outputs emit at the right moments. The key mental model: treat the component like a black box — set inputs, interact with the rendered DOM, assert on what the user sees. Avoid testing internal state directly (component.somePrivateFlag) — test the observable effect (what the template shows). This keeps tests aligned with user-visible behaviour and resilient to refactoring.

TestBed Component Testing

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By }                          from '@angular/platform-browser';
import { signal }                       from '@angular/core';

describe('PostCardComponent', () => {
  let fixture:   ComponentFixture<PostCardComponent>;
  let component: PostCardComponent;

  const mockPost: PostSummaryDto = {
    id: 1, title: 'Getting Started with .NET',
    slug: 'getting-started-dotnet', viewCount: 1234,
    authorName: 'Alice', publishedAt: '2024-07-15T00:00:00Z',
    excerpt: 'Learn the basics.',
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports:   [PostCardComponent],   // standalone component
      providers: [
        provideRouter([]),
        { provide: APP_CONFIG, useValue: { apiUrl: '', cdnBaseUrl: '' } },
      ],
    }).compileComponents();

    fixture   = TestBed.createComponent(PostCardComponent);
    component = fixture.componentInstance;

    // Set required input before first detectChanges
    fixture.componentRef.setInput('post', mockPost);
    fixture.detectChanges();
  });

  // ── Template rendering ────────────────────────────────────────────────
  it('renders the post title', () => {
    const titleEl = fixture.debugElement.query(By.css('[data-cy="post-title"]'));
    expect(titleEl.nativeElement.textContent).toContain('Getting Started with .NET');
  });

  it('formats view count with number pipe', () => {
    const viewEl = fixture.debugElement.query(By.css('[data-cy="view-count"]'));
    expect(viewEl.nativeElement.textContent).toContain('1,234');
  });

  it('does not show cover image when coverImageUrl is null', () => {
    fixture.componentRef.setInput('post', { ...mockPost, coverImageUrl: null });
    fixture.detectChanges();
    const img = fixture.debugElement.query(By.css('[data-cy="cover-image"]'));
    expect(img).toBeNull();
  });

  // ── Output testing ────────────────────────────────────────────────────
  it('emits postClicked when the card is clicked', () => {
    let emittedPost: PostSummaryDto | undefined;
    component.postClicked.subscribe(p => emittedPost = p);

    fixture.debugElement.query(By.css('[data-cy="post-card"]'))
           .nativeElement.click();

    expect(emittedPost).toEqual(mockPost);
  });

  // ── Angular Testing Library approach (cleaner) ────────────────────────
  it('shows author name — ATL style', async () => {
    const { getByText } = await render(PostCardComponent, {
      componentInputs: { post: mockPost },
      providers: [provideRouter([]),
                  { provide: APP_CONFIG, useValue: { apiUrl: '' } }],
    });

    expect(getByText('Alice')).toBeInTheDocument();
    expect(getByText('1,234')).toBeInTheDocument();
  });
});

// ── Testing skeleton loading component ────────────────────────────────────
describe('PostCardSkeletonComponent', () => {
  it('renders shimmer placeholder elements', async () => {
    const { container } = await render(PostCardSkeletonComponent);
    const skeletons = container.querySelectorAll('.skeleton-block');
    expect(skeletons.length).toBeGreaterThan(0);
  });
});
Note: fixture.componentRef.setInput('post', value) is the Angular 18 API for setting typed component inputs in tests — it respects the input’s type and triggers the signal update correctly. The older component.post = value approach still works for non-signal inputs but bypasses Angular’s input bookkeeping. Always use setInput() for @Input() bindings in Angular 17+, followed by fixture.detectChanges() to trigger template re-rendering.
Tip: Use fixture.debugElement.query(By.css('[data-cy="..."]')) for template queries in TestBed tests. The data-cy attribute selectors are stable (they don’t change with Material updates or CSS refactoring) and signal to future developers that the element is a testing anchor. Alternatively, Angular Testing Library’s getByRole and getByText produce the most user-centric tests — they query what users see, making tests serve as documentation of the component’s user interface.
Warning: Each fixture.detectChanges() call is synchronous — it processes all pending signal updates and re-renders the template. Missing a detectChanges() call after a state change means the template still shows the old values. When using Angular Testing Library’s render(), change detection is managed automatically for most interactions. In raw TestBed, always call detectChanges() after any state change that should update the template.

Common Mistakes

Mistake 1 — Testing internal component state rather than template output

❌ Wrong — expect(component.isLoading).toBe(false); tests implementation detail; passes even if loading spinner is still shown.

✅ Correct — expect(fixture.debugElement.query(By.css('[data-cy="spinner"]'))).toBeNull(); tests what user sees.

Mistake 2 — Not calling detectChanges after setInput (template shows old values)

❌ Wrong — fixture.componentRef.setInput('post', newPost) with no detectChanges; template still shows old post.

✅ Correct — always follow setInput with fixture.detectChanges().

🧠 Test Yourself

A component has an @Output() postClicked = new EventEmitter<PostSummaryDto>(). The test subscribes and clicks. But the component uses @Output() postClicked = output<PostSummaryDto>() (new signal-based output). Does the old subscription approach still work?