Angular Unit Testing Without Testbed: A Comprehensive Guide

Saunak Surani
Widle Studio LLP
Published in
14 min readAug 2, 2023

Unit testing is a critical aspect of modern software development that ensures the reliability and correctness of your code. In Angular applications, unit testing is typically done using the TestBed and various testing utilities provided by the Angular testing framework. However, in some scenarios, you might want to perform unit tests without the TestBed, which can provide more control and flexibility in certain situations.

Angular Unit Testing Without Testbed: A Comprehensive Guide

In this article, we will explore how to perform Angular unit testing without the TestBed. We will cover the benefits and limitations of this approach and provide practical examples to illustrate its implementation. By the end of this guide, you will have a clear understanding of when and how to use TestBed-free unit testing in your Angular projects.

Table of Contents

  1. Introduction to Unit Testing in Angular
    1.1. What is Unit Testing?
    1.2. Why Unit Test Angular Applications?
    1.3. Overview of Angular Testing Utilities
  2. Using TestBed for Unit Testing in Angular
    2.1. Setting Up TestBed for Component Testing
    2.2. Dependency Injection and Mocking Services
    2.3. Testing Components with External Templates
    2.4. Testing Angular Services and Pipes
    2.5. Asynchronous Testing with fakeAsync and async
  3. When to Use Unit Testing Without TestBed
    3.1. Advantages of TestBed-Free Unit Testing
    3.2. Scenarios Suitable for TestBed-Free Testing
    3.3. Limitations and Drawbacks
  4. Implementing TestBed-Free Unit Tests
    4.1. Testing Components Without TestBed
    4.2. Mocking Dependencies and Services Manually
    4.3. Testing Services and Pipes Without TestBed
    4.4. Handling Asynchronous Operations
  5. Best Practices for TestBed-Free Unit Testing
    5.1. Isolating Units and Reducing Dependencies
    5.2. Keeping Tests Independent and Isolated
    5.3. Using Spies and Stubs Effectively
  6. Real-World Examples of TestBed-Free Unit Testing
    6.1. Testing Form Validation Logic Without TestBed
    6.2. Unit Testing Custom Directives and Attributes
    6.3. Mocking HTTP Requests and Responses
  7. Integrating TestBed-Free and TestBed-Based Tests
    7.1. Combining Different Testing Approaches
    7.2. Strategies for Transitioning Existing Tests
  8. Automating TestBed-Free Unit Tests
    8.1. Setting Up Continuous Integration for TestBed-Free Tests
    8.2. Incorporating TestBed-Free Tests into Test Suites
  9. Conclusion
    9.1. Recap of TestBed-Free Unit Testing in Angular
    9.2. Choosing the Right Testing Approach for Your Project

1. Introduction to Unit Testing in Angular

1.1. What is Unit Testing?

Unit testing is a software testing methodology where individual units or components of a program are tested in isolation. The goal of unit testing is to validate that each unit of the software performs as designed and meets its specifications. In the context of Angular applications, units are typically individual functions, methods, components, or services.

Unit tests should be independent, meaning that the behavior of one unit should not depend on the behavior of other units. This allows for easier maintenance, debugging, and refactoring of code.

1.2. Why Unit Test Angular Applications?

Unit testing plays a crucial role in software development for several reasons:

  1. Identifying Bugs Early: Unit tests help detect bugs and issues at an early stage of development, making it easier and less costly to fix them.
  2. Ensuring Correctness: Unit tests ensure that each unit of the application performs its intended functionality accurately.
  3. Facilitating Refactoring: Unit tests provide confidence that code changes do not introduce unintended side effects, making refactoring safer and more efficient.
  4. Documentation: Unit tests serve as documentation of the expected behavior of each unit, which can be useful for new team members and future maintenance.
  5. Improving Code Quality: The process of writing unit tests often leads to better code design and modularity.

1.3. Overview of Angular Testing Utilities

Angular provides a powerful testing framework that includes utilities and tools to facilitate unit testing. The primary testing utilities in Angular include:

  1. TestBed: TestBed is the central utility for configuring and initializing testing modules in Angular. It allows you to create a test module that contains the components, services, and directives needed for testing a specific feature.
  2. TestComponentBuilder (deprecated): TestComponentBuilder was previously used to create and configure components for testing. However, it has been deprecated in favor of using the TestBed API directly.
  3. ComponentFixture: ComponentFixture is a wrapper around a component instance and its associated template. It provides methods to interact with the component and its DOM elements during testing.
  4. inject() and TestBed.get(): These functions are used to access services and dependencies in your test cases.
  5. fakeAsync() and tick(): These functions are used to handle asynchronous operations in tests that involve timers, HTTP requests, or other asynchronous tasks.

In the next section, we will explore how to use TestBed for unit testing in Angular and cover various testing scenarios.

2. Using TestBed for Unit Testing in Angular

Before diving into TestBed-free unit testing, let's briefly review how to use TestBed for unit testing in Angular. We will cover setting up TestBed for component testing, dependency injection, testing services and pipes, and handling asynchronous operations.

2.1. Setting Up TestBed for Component Testing

To test components in Angular, we need to set up a testing module using the TestBed API. The testing module

should import the component being tested and any relevant modules or dependencies. Additionally, we can configure the testing module to use mocked or real services as needed.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
import { MyService } from './my.service';

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

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MyComponent],
providers: [MyService], // Mock or real service
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});

In this example, we have a simple Angular component MyComponent that depends on a service MyService. The testing module is set up using TestBed.configureTestingModule(), where we declare the component and provide the MyService (which can be either a real or mocked service). The async() function is used to handle asynchronous operations in the testing module setup.

2.2. Dependency Injection and Mocking Services

In Angular, dependency injection is a core concept that allows components and services to consume other services. During unit testing, we can mock these services to isolate the unit being tested and focus on its behavior without affecting the behavior of its dependencies.

Mocking a service involves creating a simple object that mimics the behavior of the real service. We can use Jasmine's createSpyObj function to create mock objects with specific methods.

import { TestBed } from '@angular/core/testing';
import { MyService } from './my.service';
import { MyComponent } from './my.component';

describe('MyComponent', () => {
let component: MyComponent;
let myServiceSpy: jasmine.SpyObj<MyService>;

beforeEach(() => {
const spy = jasmine.createSpyObj('MyService', ['getData']);

TestBed.configureTestingModule({
declarations: [MyComponent],
providers: [{ provide: MyService, useValue: spy }],
});

myServiceSpy = TestBed.inject(MyService) as jasmine.SpyObj<MyService>;
component = TestBed.createComponent(MyComponent).componentInstance;
});

it('should call MyService.getData() on initialization', () => {
component.ngOnInit();
expect(myServiceSpy.getData).toHaveBeenCalled();
});
});

In this example, we are testing a component that depends on MyService. We create a mock of MyService using createSpyObj and provide it to the testing module using the useValue property. Then, we use TestBed.inject() to get the instance of the MyService spy, and we can set up expectations on its methods and behaviors.

2.3. Testing Components with External Templates

Sometimes, components have external templates that are loaded via the templateUrl property. When testing such components, we need to tell the testing module to fetch and compile the external template.

import { TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';

describe('MyComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MyComponent],
})
.compileComponents();
});

it('should render the component template', () => {
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();

const compiled = fixture.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Hello, World!');
});
});

In this example, we are testing a component MyComponent with an external template file. We use compileComponents() to fetch and compile the external template before creating the component fixture. Then, we can access the compiled native element and perform assertions on it.

2.4. Testing Angular Services and Pipes

Testing Angular services and pipes is straightforward using TestBed. We can inject the service or pipe using the TestBed.inject() function and then perform tests on its methods and behavior.

import { TestBed } from '@angular/core/testing';
import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MyService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

it('should return data', () => {
const data = service.getData();
expect(data).toBeDefined();
// Add more assertions based on the service behavior
});
});

Similarly, we can test Angular pipes by injecting them and calling their transform method with test data.

2.5. Asynchronous Testing with fakeAsync and async

Angular applications often involve asynchronous operations, such as HTTP requests or timers. To test these asynchronous operations, Angular provides the fakeAsync() and async() functions.

The fakeAsync() function allows us to write synchronous-looking tests while still handling asynchronous operations.

import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { MyService } from './my.service';

describe('MyService', () => {
let

service: MyService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MyService);
});

it('should fetch data asynchronously', fakeAsync(() => {
let data: any;
service.getData().then((result) => data = result);
tick(); // Advance time to resolve the promise
expect(data).toBeDefined();
// Add more assertions based on the asynchronous operation
}));
});

The tick() function is used to advance time to resolve the promise returned by the asynchronous operation.

Alternatively, we can use the async() function to handle asynchronous operations with promises and observables.

import { TestBed, async } from '@angular/core/testing';
import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MyService);
});

it('should fetch data asynchronously', async(() => {
let data: any;
service.getData().then((result) => data = result);
expect(data).toBeDefined();
// Add more assertions based on the asynchronous operation
}));
});

Both fakeAsync() and async() provide convenient ways to handle asynchronous testing in Angular applications.

In the next section, we will explore scenarios where TestBed-free unit testing can be beneficial and how to implement it effectively.

3. When to Use Unit Testing Without TestBed

While TestBed is a powerful and widely used testing utility in Angular, there are scenarios where TestBed-free unit testing can be advantageous.

3.1. Advantages of TestBed-Free Unit Testing

  1. Increased Flexibility: TestBed-free unit testing allows more granular control over individual units and their dependencies, providing greater flexibility in test implementation.
  2. Simplified Setup: Writing tests without TestBed can result in less boilerplate code, making the test code more concise and easier to read.
  3. Performance: TestBed-free tests can be faster to execute since they do not require the setup and teardown of the testing module.
  4. Focused Testing: TestBed-free tests can be useful for focusing on specific units or functionality, especially when you don't need to test interactions with Angular modules or components.
  5. Legacy Code and Third-Party Libraries: In scenarios where you have legacy code or third-party libraries that are challenging to test using TestBed, TestBed-free testing can be a viable alternative.

3.2. Scenarios Suitable for TestBed-Free Testing

Here are some scenarios where TestBed-free unit testing might be applicable:

  1. Pure Functions and Utility Functions: Pure functions and utility functions that do not have dependencies on Angular modules or services can be effectively tested without the need for TestBed.
  2. Custom Directives: Simple custom directives that do not require TestBed configurations can be tested without TestBed.
  3. Pure Pipes: Pure pipes that perform simple transformations can be tested without TestBed.
  4. Utility Services: Services that act as utility classes and do not require Angular module configurations can be tested without TestBed.

3.3. Limitations and Drawbacks

While TestBed-free testing can be advantageous in certain scenarios, it also has some limitations and drawbacks:

  1. Dependency Injection: TestBed provides a convenient way to inject and configure dependencies, which can be more challenging in TestBed-free testing.
  2. Integration Testing: TestBed-free testing is not suitable for integration testing, where interactions between components and services need to be tested.
  3. Angular Features: TestBed-free testing is not recommended for testing Angular-specific features, such as component templates, custom decorators, or interactions with Angular modules.
  4. Testing Angular Modules: TestBed is essential for testing Angular modules, as it provides the necessary configuration and setup.

In the next section, we will explore how to implement TestBed-free unit tests for various scenarios in Angular applications.

4. Implementing TestBed-Free Unit Tests

To perform TestBed-free unit testing, we need to create and isolate the units being tested without relying on TestBed configurations. This involves manually creating instances of components, services, and other units and mocking their dependencies as needed.

4.1. Testing Components Without TestBed

To test components without TestBed, we can manually instantiate the component and its dependencies, such as services and pipes.

import { MyComponent } from './my.component';
import { MyService } from './my.service';

describe('MyComponent', () => {
let component: MyComponent;
let myServiceMock: MyService;

beforeEach(() => {
myServiceMock = jasmine.createSpyObj('MyService', ['getData']);
component = new MyComponent(myServiceMock);
});

it('should call MyService.getData() on initialization', () => {
component.ngOnInit();
expect(myServiceMock.getData).toHaveBeenCalled();
});
});

In this example, we manually create an instance of MyComponent and provide a mock of MyService as a dependency.

4.2. Mocking Dependencies and Services Manually

To mock dependencies and services manually, we can use Jasmine's createSpyObj function to create mock objects and provide them as dependencies.

import { MyComponent } from './my.component';
import { MyService } from './my.service';

describe('MyComponent', () => {
let component: MyComponent;
let myServiceMock: jasmine.SpyObj<MyService>;

beforeEach(() => {
myServiceMock = jasmine.createSpyObj('MyService', ['getData']);
component = new MyComponent(myServiceMock);
});

it('should call MyService.getData() on initialization', () => {
component.ngOnInit();
expect(myServiceMock.getData).toHaveBeenCalled();
});
});

In this example, we use createSpyObj to create a mock object for MyService with a single method, getData. We provide this mock as the dependency when creating an instance of MyComponent.

4.3. Testing Services and Pipes Without TestBed

Testing services and pipes without TestBed involves creating instances of the services or pipes and manually invoking their methods with test data.

import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;

beforeEach(() => {
service = new MyService();
});

it('should be created', () => {
expect(service).toBeTruthy();
});

it('should return data', () => {
const data = service.getData();
expect(data).toBeDefined();
// Add more assertions based on the service behavior
});
});

In this example, we manually create an instance of MyService and test its methods with test data.

4.4. Handling Asynchronous Operations

In TestBed-free unit testing, we need to handle asynchronous operations manually, similar to how we did it with fakeAsync() and async() in TestBed-based testing.

For example, if a service method returns a Promise, we can use async/await to handle the asynchronous operation.

import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;

beforeEach(() => {
service = new MyService();
});

it('should fetch data asynchronously', async () => {
const data = await service.getData();
expect(data).toBeDefined();
// Add more assertions based on the asynchronous operation
});
});

Alternatively, we can use .then() to handle the Promise.

import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;

beforeEach(() => {
service = new MyService();
});

it('should fetch data asynchronously', (done) => {
service.getData().then((data) => {
expect(data).toBeDefined();
// Add more assertions based on the asynchronous operation
done();
});
});
});

For asynchronous operations involving timers, we can use jasmine.clock() to mock time.

In the next section, we will explore best practices for TestBed-free unit testing in Angular.

5. Best Practices for TestBed-Free Unit Testing

While TestBed-free unit testing can be advantageous in certain scenarios, it is essential to follow best practices to ensure the effectiveness and maintainability of your tests.

5.1. Isolating Units and Reducing Dependencies

When writing TestBed-free unit tests, aim to isolate the unit being tested and minimize its dependencies. The more isolated a unit is, the easier it is to test and understand its behavior.

For example, when testing a component, mock its services and dependencies, and focus on testing the component's logic without involving the entire Angular module.

import { MyComponent } from './my.component';
import { MyService } from './my.service';
import { MyPipe } from './my.pipe';

describe('MyComponent', () => {
let component: MyComponent;
let myServiceMock: jasmine.SpyObj<MyService>;

beforeEach(() => {
myServiceMock = jasmine.createSpyObj('MyService', ['getData']);
component = new MyComponent(myServiceMock, new MyPipe());
});

it('should render data', () => {
myServiceMock.getData.and.returnValue('Hello, World!');
expect(component.getDataText()).toBe('Hello, World!');
});
});

In this example, we mock MyService and provide a real instance of MyPipe as dependencies for MyComponent. This way, we isolate the component and focus on testing its behavior.

5.2. Keeping Tests Independent and Isolated

Each unit test should be independent and not rely on the state or behavior of other tests. Avoid sharing data or side effects between tests to ensure that each test is predictable and reliable.

5.3. Using Spies and Stubs Effectively

Jasmine provides powerful tools like spies and stubs to observe and control the behavior of functions and methods. Use spies and stubs effectively to mock dependencies and observe method calls.

import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;
let getDataSpy: jasmine.Spy;

beforeEach(() => {
service = new MyService();
getDataSpy = spyOn(service, 'getData').and.returnValue('Mocked Data');
});

it('should return mocked data', () => {
const data = service.getData();
expect(data).toBe('Mocked Data');
expect(getDataSpy).toHaveBeenCalled();
});
});

In this example, we use a spy to observe the getData() method of MyService and return mocked data.

6. Real-World Examples of TestBed-Free Unit Testing

In this section, we will explore real-world examples of TestBed-free unit testing in Angular applications.

6.1. Testing Form Validation Logic Without TestBed

Angular applications often contain forms with complex validation logic. TestBed-free testing can be useful for testing form validation functions and methods in isolation.

import { FormValidator } from './form.validator';

describe('FormValidator', () => {
it('should validate email addresses', () => {
expect(FormValidator.email('test@example.com')).toBeNull();
expect(FormValidator.email('invalid')).toEqual({ invalidEmail: true });
});

it('should validate passwords', () => {
expect(FormValidator.password('P@ssw0rd')).toBeNull();
expect(FormValidator.password('weak')).toEqual({ weakPassword: true });
});
});

In this example, we have a FormValidator class that contains functions for validating email addresses and passwords. We can test these validation functions without the need for TestBed.

6.2. Unit Testing Custom Directives and Attributes

Custom directives and attributes can be tested without TestBed by creating instances of the directive and testing their behavior.

import { MyDirective } from './my.directive';
import { ElementRef } from '@angular/core';

describe('MyDirective', () => {
it('should apply custom style to element', () => {
const directive = new MyDirective(new ElementRef(document.createElement('div')));
directive.ngOnInit();
expect(directive.elementRef.nativeElement.style.color).toBe('red');
});
});

In this example, we have a custom directive MyDirective that applies a custom style to the host element. We can test this behavior by creating an instance of the directive and checking the applied style.

6.3. Mocking HTTP Requests and Responses

Testing HTTP requests and responses can be challenging using TestBed. With TestBed-free testing, we can manually mock the HTTP service and test the application's behavior with various HTTP response scenarios.

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { MyService } from './my.service';

describe('MyService', () => {
let service: MyService;
let httpMock: jasmine.SpyObj<HttpClient>;

beforeEach(() => {
httpMock = jasmine.createSpyObj('HttpClient', ['get']);
service = new MyService(httpMock);
});

it('should fetch data from the server', () => {
const testData = { message: 'Hello, World!' };
httpMock.get.and.returnValue(of(testData));

let responseData;
service.getData().subscribe((data) => responseData = data);

expect(httpMock.get).toHaveBeenCalledWith('

https://api.example.com/data');
expect(responseData).toEqual(testData);
});

it('should handle server errors', () => {
const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
httpMock.get.and.returnValue(throwError(errorResponse));

let errorMessage;
service.getData().subscribe(
() => {},
(error) => errorMessage = error.message
);

expect(httpMock.get).toHaveBeenCalledWith('https://api.example.com/data');
expect(errorMessage).toBe('Server Error');
});
});

In this example, we have a MyService that makes an HTTP request to fetch data from the server. We manually create a mock of HttpClient and provide it to the service. We can then test various scenarios, such as successful responses and error handling.

7. Integrating TestBed-Free and TestBed-Based Tests

In many Angular applications, a combination of TestBed-free and TestBed-based tests can be employed to cover various testing scenarios effectively.

For example, TestBed-based tests can be used for testing components with complex templates, testing interactions between components and services, and integration testing of Angular modules. On the other hand, TestBed-free tests can be utilized for testing simple components, pure functions, utility services, and custom directives.

To integrate both testing approaches, organize your tests in separate test files and use descriptive naming conventions to differentiate between TestBed-based and TestBed-free tests.

8. Automating TestBed-Free Unit Tests

To ensure the reliability and consistency of your TestBed-free unit tests, it's essential to automate them as part of your continuous integration (CI) process.

You can use popular CI tools like Jenkins, Travis CI, or CircleCI to automate the execution of your unit tests. Additionally, you can integrate your TestBed-free tests into your existing test suites and use the test runner provided by Angular CLI to run all your tests simultaneously.

9. Conclusion

In this comprehensive guide, we have explored TestBed-based and TestBed-free unit testing in Angular applications. We learned how to set up TestBed for unit testing, handle asynchronous operations, and mock dependencies. We also examined the scenarios where TestBed-free testing can be beneficial and the best practices for implementing it effectively.

By combining both TestBed-based and TestBed-free testing approaches, you can create a robust testing strategy that ensures the quality and reliability of your Angular applications. Always choose the appropriate testing approach based on the complexity of the unit being tested and the specific requirements of your project.

Remember, unit testing is an essential practice for building maintainable and bug-free Angular applications. So, embrace testing as an integral part of your development workflow and strive for test coverage that ensures the stability and longevity of your codebase. Happy testing!

--

--

Saunak Surani
Widle Studio LLP

Passionate about technology, design, startups, and personal development. Bringing ideas to life at https://widle.studio