Why Unit Tests Matter

Throughout my career, I have encountered numerous applications that lack any form of unit testing. It is important to understand the rationale behind writing unit tests. They are an essential component of any software project, offering both assurance and validation of how the code is expected to function. Additionally, they act as living documentation, clarifying what the code accomplishes. Crafting effective tests also illuminates the code's design quality; if a unit test is difficult to write, it often signals poor design and suggests that a refactor is necessary.

Tests that closely mimic real-world usage provide the highest level of confidence in your software.

The Importance of Mocking

To focus exclusively on the code under test, it is crucial to mock external dependencies properly. This includes any services or components that the target component relies on. Importing the actual implementations is generally discouraged, as detailed below. However, importing pure components that are used as direct dependents is acceptable. Alternatively, you might import a shared module that consolidates all necessary dependencies.

Consequences of Skipping Mocks

  • You rely on the real implementation, forcing you to mock its internal properties and methods. This can lead to a deep rabbit hole where you end up mocking classes several levels down the dependency tree.
  • It becomes necessary to declare nested components and provide all their dependencies explicitly.
  • Test execution slows down because the entire dependency tree must be instantiated before the test can run.
  • The state of your tests may be unpredictable or incorrect.
  • Tests can break without warning when a downstream dependency changes.
  • Debugging errors becomes considerably more challenging.

SIFERS: A Direct Setup Approach

For setting up the testing environment, I prefer using SIFERS over the conventional beforeEach block.

Simple Injectable Functions Explicitly Returning State (SIFERS) encapsulate the setup process for tests while providing a clean, mutable state.

The core of SIFERS is a setup function designed to configure the testing environment, accepting optional arguments. This is its primary advantage over beforeEach, which is invoked automatically before each test runs, preventing the customization of mocked values essential for component or service initialization.

By using SIFERS, you gain greater flexibility, allowing you to set up mocks before your component or service is instantiated. This setup function is explicitly called in every test and can return a state object (containing classes, properties, etc.) for the test to use.

A good practice when using SIFERS is to keep the number of arguments to the setup function minimal. If the list of parameters grows, grouping them into an interface is advisable. This approach helps maintain clean and readable code.

A basic setup function example is shown here:

function setup({ value = false }) {
  const mockService: Partial<RealService> = {
    someFunction: jest.fn()
      .mockReturnValue(value ? 'foo' : 'bar'),
  };

  const service = new MyService(mockService);
  return {
    service,
    mockService,
  };
}
Enter fullscreen mode Exit fullscreen mode

With the setup function above, the corresponding tests can be written as follows:

it('returns foo when feature flag is enabled', () => {
  // Pass true into the setup to ensure that 
  // someFunction returns foo
  const { service } = setup(true);
  expect(service.someFunction()).toEqual('foo');
});

it('returns bar when feature flag is disabled', () => {
  // Pass false into the setup to ensure that 
  // someFunction returns bar
  const { service } = setup(false);
  expect(service.someFunction()).toEqual('bar');
});
Enter fullscreen mode Exit fullscreen mode

I won't delve into all the specifics of SIFERS here, as the author, Moshe Kolodny, explains it in great detail in his work.

Explore Testing with SIFERS here

Leveraging the Angular Testing Library (ATL)

I am a strong advocate for the ATL and incorporate it into virtually all my projects. It provides a lightweight methodology for testing Angular components. Its purpose is well-summarized as:

Angular Testing Library provides utility functions to interact with Angular components, in the same way as a user would.

Tim Deschryver

The primary distinction when setting up the test module is using the render method instead of TestBed.configureTestingModule. It's important to note that the render method is specifically designed for testing components. Services can be tested without ATL or the render method.

Are you looking for examples? A comprehensive collection showcasing everything from components, forms, and input/output patterns to NGRX, directives, Angular Material, and Signals is available here. I also highly recommend Tim Deschryver's detailed article, which is full of excellent examples.

The following example demonstrates combining a SIFER with the render method. You'll also notice the use of the createSpyFromClass method to automatically mock classes, handling all their functions, properties, and even observables. We'll explore this in more detail later in the article.

import { render } from '@testing-library/angular';
import { createSpyFromClass } from 'jest-auto-spies';
// ... other imports

async function setup({ enableFlag = false }) {
  const mockMySomeService = createSpyFromClass(MyService);
  mockMySomeService.doSomething.mockReturnValue(enableFlag);

  const { fixture } = await render(AppComponent, {
    imports: [...],
    providers: [{ 
      provide: MyService, 
      useValue: mockMySomeService 
    }],
  });
}
Enter fullscreen mode Exit fullscreen mode

Configuring Declarations

Just like with TestBed, you can pass an array of components and directives using the declarations property. The syntax mirrors the standard approach.

However, a crucial exception exists: if you're importing a module that already declares the component you're testing, you must set excludeComponentDeclaration to true.

For a comprehensive list of other useful properties, please refer to the full ATL API documentation.

Configuring Providers

To specify the providers for your component within the ATL context, you should use the componentProviders option.

Managing @Input and @Output

To set the @Input and @Output properties of a component, the componentProperties option is invaluable, allowing you to configure both simultaneously.

When finer control is needed, you can opt for componentInputs or componentOutputs individually. In a traditional TestBed setup, you would typically set inputs by directly accessing the component instance.

Effective Service Testing

When testing services, utilizing ATL or TestBed is not required. Instead, you can instantiate the service directly by passing its mocked dependencies into its constructor. The example below illustrates this by mocking both the LogService and TableService.

// some.service.ts
@Injectable({ providedIn: 'root' })
export class SomeService {
  constructor(
    private readonly logService: LogService, 
    private readonly tableService: TableService) {}
}

// some.service.spec.ts
async function setup() {
  const mockLogService = createSpyFromClass(LogService);
  const mockTableService = createSpyFromClass(TableService);

  const service = new SomeService(
    mockLogService, 
    mockTableService
  );

  return {
    service,
    mockLogService,
    mockTableService,
  };
}
Enter fullscreen mode Exit fullscreen mode

Component Testing Strategies

When writing tests for components, focus exclusively on the behavior exposed through the public interface. Private methods should never be tested in isolation. Leverage the DOM as your primary testing surface. This mirrors what an actual user would experience and is the approach the Angular Testing Library (ATL) encourages. This technique is commonly referred to as shallow testing.

Resist the temptation to call every public method directly from your test suite. These methods are public solely for the benefit of your template. They are triggered through DOM interactions, such as clicking a button, and your tests should replicate that flow.

Consider a component that requires testing:

// app-foo.component.ts
@Component({
  selector: 'app-foo',
  template: `
    <input 
      data-testid='my-input'
      (keydown)='handleKeyDown($event)' />`
})
export class FooComponent {
  constructor(private readonly someService: SomeService) {}

  handleKeyDown(value: string) {
    this.someService.foo(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

A standard SIFER setup configuration might resemble this:

// app-foo.component.spec.ts
async function setup() {
  const mockSomeService = createSpyFromClass(SomeService);
  const { fixture } = await render(FooComponent, {
    providers: [{ 
      provide: SomeService, 
      useValue: mockSomeService 
    }],
  });

  return {
    fixture,
    mockSomeService,
    fixture.componentInstance
  }
}
Enter fullscreen mode Exit fullscreen mode

Avoid the following anti-pattern. This test circumvents the template by invoking the public method directly. The test would continue to pass even if the input element in the DOM were removed entirely. Such a test is a false positive and provides no real value.

it('emits a value', async () => {
  const { mockSomeService, component } = await setup(...);
  component.handleKeyDown(value);

  expect(mockSomeService.foo)
    .toHaveBeenCalledWith(value);
})
Enter fullscreen mode Exit fullscreen mode

The correct approach tests the function through user interaction. Here, the screen object locates the input element, and userEvent simulates the DOM events.

import { screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';

it('emits a value', async () => {
  const { mockSomeService, component } = await setup(...);
  const textbox = screen.queryByTestId('my-input');

  userEvent.type(textbox, 'foo,');
  userEvent.keyboard('{Enter}');

  expect(mockSomeService.foo)
    .toHaveBeenCalledWith(value);
})
Enter fullscreen mode Exit fullscreen mode

Querying the DOM with screen

The screen API offers a range of powerful methods for querying the DOM. Functions such as waitFor and those starting with findBy return promises, which makes them suitable for locating elements that are conditionally displayed.

When querying, follow this recommended priority order. Refer to the full priority list for detailed descriptions of each method.

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue
  6. getByAltText
  7. getByTitle
  8. getByTestId

Simulating DOM Interactions

ATL provides two distinct APIs for dispatching events through the DOM:

It's advisable to use userEvent instead of fireEvents, which comes from the Events API. According to the official documentation, the key distinction is:

fireEvent dispatches DOM events, whereas user-event simulates full interactions, which may fire multiple events and do additional checks along the way.

Streamlining Mocks with jest-auto-spies

For mocking classes, jest-auto-spies is a valuable tool. It generates a type-safe mock class automatically, eliminating the need to manually define each function and property. Besides saving time, it offers utility functions for observables, methods, getters, and setters. The following example illustrates the use of createSpyFromClass to construct a spy class.

To inject dependencies directly into a module, use provideAutoSpy(MyClass). This is a convenient shorthand for {provide: MyClass, useValue: createSpyFromClass(MyClass)}.

Keep in mind that this convenience is best suited for scenarios where you don't need to stub any specific methods. If you need to control the behavior of a particular method, instantiate the mock and provide it directly.

Here are some practical examples:

Creating a Basic Class Spy

const mockMyService = createSpyFromClass(MyService);
Enter fullscreen mode Exit fullscreen mode

Creating a Spy with Observable Emission

const mockMyService = createSpyFromClass(MyService, {
    observablePropsToSpyOn: ['foo$'],
});

mockMyService.foo$.nextWith('bar');
Enter fullscreen mode Exit fullscreen mode

Creating a Spy on a Class and its Method

const mockMyService = createSpyFromClass(MyService, {
    methodsToSpyOn: ['foo'],
});

mockMyService.foo.mockReturnValue('bar');
Enter fullscreen mode Exit fullscreen mode

Explore the jest-auto-spies documentation for additional helper functions and advanced usage patterns.

Improving Async Tests with observer-spy

For asynchronous testing, the subscribeSpyTo function from the observer-spy library is a superior alternative to manual subscription. It also eliminates the need for the done callback.

The done callback, historically used for async tests, is prone to errors and unpredictable behavior. Its use can lead to false positives, where tests pass incorrectly, or timeouts that cause intermittent failures.

A dedicated lint rule exists to enforce the prohibition of the done callback.

Another benefit is that manual unsubscription is unnecessary. The library provides an automatic unsubscribe hook
that cleans up during the afterEach lifecycle hook.

Below are examples from the library's readme illustrating its usage. Check the complete documentation for more scenarios.

const fakeObservable = of('first', 'second', 'third');
const observerSpy = subscribeSpyTo(fakeObservable);

// No need to unsubscribe, as the have an auto-unsubscribe in place.
// observerSpy.unsubscribe();

// Expectations:
expect(observerSpy.getFirstValue()).toBe('first');
expect(observerSpy.receivedNext()).toBeTruthy();
expect(observerSpy.getValues()).toEqual(fakeValues);
expect(observerSpy.getValuesLength()).toBe(3);
expect(observerSpy.getValueAt(1)).toBe('second');
expect(observerSpy.getLastValue()).toBe('third');
expect(observerSpy.receivedComplete()).toBeTruthy();
Enter fullscreen mode Exit fullscreen mode

Further Reading

Wrapping Up

This walkthrough shared practical lessons from working with unit tests in Angular, underscoring their value for keeping code robust and easy to maintain. A central theme was the need for careful mocking to isolate individual units so tests never depend on real underlying services. SIFERS emerged as a versatile pattern for configuring the test environment while also making test cases more readable. For component coverage, the Angular Testing Library (ATL) proved especially useful since it encourages tests that interact with components the way actual users would. The post also pointed to helpers such as jest-auto-spies for mocking classes efficiently, and directed readers to additional material on Angular testing for those interested in digging deeper.


🤝 Let's Stay in Touch

If this guide was helpful, feel free to reach out and connect:

🔗 Follow me on LinkedIn
💻 Check out my GitHub
Buy me a coffee