Everyone agrees that testing is important. Very few teams agree on what to test, how much to test, and what tools to use. Over the past several years I have experimented with different testing approaches across multiple frontend projects, and I have landed on a strategy that balances confidence with pragmatism. The goal is not 100 percent coverage. The goal is catching the bugs that matter before they reach users.

Rethinking the Testing Pyramid

The traditional testing pyramid says you should have lots of unit tests at the base, some integration tests in the middle, and a few E2E tests at the top. For backend code, this makes sense. For frontend code, I have found the pyramid should be more like a diamond or trophy shape. The most valuable tests for a frontend application are integration tests that render a component with its immediate dependencies and verify behavior from a user perspective.

Unit tests for pure utility functions are great. Unit tests for components that test implementation details like "did this method get called" are a waste of time. They break on every refactor and never catch real bugs.

Component Testing with Testing Library

Testing Library changed how I think about frontend tests. The principle is simple: test your components the way users interact with them. Do not test internal state, do not spy on private methods. Query by role, label, and text content. Here is what this looks like in practice:

// login-form.component.spec.ts
import { render, screen, fireEvent } from '@testing-library/angular';
import { LoginFormComponent } from './login-form.component';

describe('LoginFormComponent', () => {
  it('should display validation error for empty email', async () => {
    await render(LoginFormComponent);

    const submitButton = screen.getByRole('button', { name: /sign in/i });
    fireEvent.click(submitButton);

    expect(screen.getByText(/email is required/i)).toBeTruthy();
  });

  it('should call onSubmit with credentials', async () => {
    const onSubmit = jest.fn();
    await render(LoginFormComponent, {
      componentProperties: { onSubmit: { emit: onSubmit } as any }
    });

    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);

    await fireEvent.input(emailInput, { target: { value: 'user@test.com' } });
    await fireEvent.input(passwordInput, { target: { value: 'password123' } });
    fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'user@test.com',
      password: 'password123'
    });
  });
});

Notice that these tests do not care about the component's internal form implementation. They do not know whether it uses reactive forms or template-driven forms. They interact with the rendered DOM just like a user would. If you refactor the component's internals, these tests keep passing as long as the behavior stays the same.

E2E Testing with Cypress

For end-to-end tests, I use Cypress for the critical user journeys. Not every feature needs an E2E test. I focus on the flows that, if broken, would generate support tickets or lose revenue. For our chat application, that means:

// cypress/e2e/chat-flow.cy.ts
describe('Chat Flow', () => {
  beforeEach(() => {
    cy.intercept('GET', '/api/conversations', {
      fixture: 'conversations.json'
    }).as('getConversations');
    cy.login('test@example.com', 'password');
  });

  it('should send a message and see it in the conversation', () => {
    cy.wait('@getConversations');
    cy.contains('John Doe').click();

    cy.get('[data-cy="message-input"]').type('Hello from Cypress');
    cy.get('[data-cy="send-button"]').click();

    cy.get('[data-cy="message-list"]')
      .should('contain', 'Hello from Cypress');
  });

  it('should show typing indicator when other user types', () => {
    cy.wait('@getConversations');
    cy.contains('John Doe').click();

    // Simulate WebSocket event
    cy.window().then(win => {
      win.dispatchEvent(new CustomEvent('ws:typing', {
        detail: { userId: 'john-doe', conversationId: 'conv-1' }
      }));
    });

    cy.get('[data-cy="typing-indicator"]').should('be.visible');
  });
});

Mocking Strategies

Good mocking is the difference between tests that are useful and tests that are a maintenance burden. My rules of thumb: mock the network boundary (HTTP calls, WebSocket connections), never mock the module under test, and prefer realistic fake data over empty objects.

For HTTP mocking in Angular, I prefer HttpClientTestingModule for unit tests and Cypress intercepts for E2E tests. For WebSocket connections, I create a simple mock class that implements the same interface:

class MockWebSocketService {
  private messages$ = new Subject<ChatMessage>();

  connect() { return of(true); }
  disconnect() {}
  onMessage() { return this.messages$.asObservable(); }

  // Test helper to simulate incoming messages
  simulateMessage(msg: ChatMessage) {
    this.messages$.next(msg);
  }
}

The key insight is that your mock should be simple but realistic enough to exercise the code paths you care about. If your mock always returns empty data, you are not testing anything meaningful.

What Not to Test

Knowing what not to test is just as important as knowing what to test. I do not write tests for third-party library behavior, for trivial getters and setters, for CSS styling (use visual regression tools if you care about that), or for generated code. I also do not aim for coverage numbers. A team obsessing over hitting 90 percent coverage will write a lot of low-value tests just to move the number. Focus on testing behavior that matters to users, and the coverage will be respectable without being a goal unto itself.

This approach has served me well across three major projects. It takes discipline to resist the urge to test everything, but the result is a test suite that runs fast, catches real bugs, and does not fight you on every refactor.