Solitary Unit Tests
Introductionβ
Solitary Unit Tests, or isolated unit tests, aim to evaluate a single unit of work entirely separate from its external dependencies. These tests leverage test doubles, such as mocks and stubs, to mimic the behavior of these dependencies. This method is important for confirming the functionality and reliability of individual units within a system, ensuring that each part performs as expected under controlled conditions.
In contrast, Sociable Unit Tests involve real implementations of dependencies to verify the interactions between multiple units. However, sociable tests still mock the dependencies of the dependencies to maintain control over the test environment.
Step-by-Step Exampleβ
In this example, we have a UserService
class that depends on a UserApi
class to fetch a random user and a Database
class to save the user. The UserApi
depends on an HttpService
to make HTTP requests. We'll mock
the UserApi
, Database
, and HttpService
classes to test the UserService
class in isolation.
π‘ Please note that this example is agnostic to the mocking library (we'll use Jest) and any specific DI framework's adapter. The injection mechanism might differ based on the DI framework.
Step 1: Define the Classesβ
Here are the interfaces and classes we'll use in our example. Consider the UserService
class as the unit under test:
export interface User {
id: number;
name: string;
}
export interface IncomingEvent {
type: string;
data: unknown;
}
import { User } from './types';
@Injectable()
export class HttpService {
async get(url: string): Promise<unknown> { /* Make HTTP request */ }
}
@Injectable()
export class UserApi {
constructor(private http: HttpService) {}
async getRandom(): Promise<User> {
const response = await this.http.get('/random-user');
return response.data;
}
}
@Injectable()
export class Database {
async saveUser(user: User): Promise<number> { /* Saves user to the database */ }
}
import { User, Database } from './services';
import { UserApi } from './user-api';
@Injectable()
export class UserService {
constructor(private userApi: UserApi, private database: Database) {}
async generateRandomUser(): Promise<number | boolean> {
try {
const user = await this.userApi.getRandom();
return this.database.saveUser(user);
} catch (error) {
return false;
}
}
}
Step 2: Set Up the Testβ
To test the UserService
class in isolation, we'll use the TestBed
factory from @suites/unit
package to create our
test environment. Hereβs a basic setup and test for UserService
:
import { TestBed, Mocked } from '@suites/unit';
import { UserService } from './user.service';
import { UserApi, HttpService, Database } from './services';
import { User } from './types';
describe('User Service Unit Spec', () => {
let underTest: UserService;
// Declare the mock instances
let userApi: Mocked<UserApi>;
let database: Mocked<Database>;
beforeAll(async () => {
// Create the test environment
const { unit, unitRef } = await TestBed.solitary(UserService).compile();
underTest = unit;
// Retrieve the mock instances
userApi = unitRef.get(UserApi);
database = unitRef.get(Database);
});
it('should generate a random user and save to the database', async () => {
userApi.getRandom.mockResolvedValue({ id: 1, name: 'John' } as User);
await underTest.generateRandomUser();
expect(database.saveUser).toHaveBeenCalledWith(userFixture);
});
});
π‘ The
Mocked
type is used to type the mocked instances of the classes. This type is provided by the@suites/unit
package. This type relies on the mocking library used in the test environment.
Automatic Mocking of Dependencies
When the class under test is instantiated using TestBed.solitary()
, all its dependencies are automatically mocked.
Initially, these stubs (mocks) have no predefined values or behaviors. This setup allows you to define the specific
behaviors you need for each test, providing precise control over the testing conditions.
Step 3: Using Suites Mocking API to Define Mock Behaviorβ
Using .mock().final()
for Final Mock Behavior
Suites provides a more declarative way to specify mock implementations using the .mock().final()
method chain. This method defines the final behavior of the mocks and doesn't allow further stubbing.
Here's how we can use this approach:
beforeAll(async () => {
const { unit } = await TestBed.solitary(UserService)
.mock(UserApi)
.final({ getRandom: async () => ({ id: 1, name: 'John' }) })
.compile();
underTest = unit;
});
In this approach, we've defined the mock behavior directly in the test setup using .mock().final()
. This finalizes the behavior of the getRandom
method, ensuring it cannot be changed in the test suite, which can be useful for ensuring consistent behavior across tests.
Notice that this value cannot be retrieved from the unit reference as it is a final mock implementation.
Using .mock().impl()
for Flexible Mock Behavior
To define mock behavior while still allowing control and monitoring during tests, use .mock().impl()
. This approach employs a callback to dynamically create stubs using the installed mocking library.
Here's how we can modify the test setup to use this approach:
beforeAll(async () => {
const { unit, unitRef } = await TestBed.solitary(UserService)
.mock(UserApi)
.impl(stubFn => ({ getRandom: stubFn().mockResolvedValue({ id: 1, name: 'John' }) }))
.compile();
underTest = unit;
userApi = unitRef.get(UserApi);
database = unitRef.get(Database);
});
test('should generate a random user and save to the database', async () => {
const userFixture: User = { id: 1, name: 'John' };
await underTest.generateRandomUser();
expect(userApi.getRandom).toHaveBeenCalled();
expect(database.saveUser).toHaveBeenCalledWith(userFixture);
});
In this setup, the .mock().impl()
method allows defining the behavior of the getRandom
method using a stub function.
The stubFn
is equivalent to the stub function from the installed mocking library (e.g., jest.fn()
), but it is
provided within the callback for convenience and abstraction.
π‘ Refer to the Suites API section for details on the mocking API.
Next Stepsβ
Solitary unit tests provide a robust foundation for verifying individual components in isolation. However, to ensure the reliability of your entire system, it's also important to test interactions between components. This is where Sociable Unit Tests come into play, enabling you to expose certain dependencies and test the interactions across multiple units within a controlled environment.
By combining both solitary and sociable unit tests, you can achieve a comprehensive testing strategy that ensures each component works correctly on its own and in conjunction with others.