Sociable Unit Tests
Introduction
Sociable Unit Tests, also known as integrated unit tests, focus on testing a unit of work in conjunction with its real dependencies, but still mock the dependencies of those dependencies. This approach ensures that the interactions between a unit and its immediate collaborators are tested in a controlled environment, providing a broader scope of validation compared to solitary unit tests.
In contrast to Solitary Unit Tests, where all dependencies are mocked, sociable tests expose certain dependencies to verify real interactions between units. However, they do not extend to the level of integration tests, which typically involve actual I/O operations and full system interactions.
Step-by-Step Example
Continuing from our previous example with the UserService
class, we'll now set up a sociable unit test. We'll expose the UserApi
dependency to test real interactions while mocking the HttpService
and Database
dependencies.
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, and we will expose the UserApi
dependency.
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> { /* Save 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 with a real UserApi
dependency, we'll use the TestBed
factory from the
@suites/unit
package to create our test environment.
Here's how we can set up the test:
Simple Test Example
Here’s a basic setup and test for UserService
using sociable unit tests:
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: UserApi;
let database: Mocked<Database>;
let httpService: Mocked<HttpService>;
beforeAll(async () => {
// Create the test environment with UserApi exposed
const { unit, unitRef } = await TestBed.sociable(UserService).expose(UserApi).compile();
underTest = unit;
// Retrieve the mock instances
database = unitRef.get(Database);
httpService = unitRef.get(HttpService);
});
it('should generate a random user and save to the database', async () => {
const userFixture: User = { id: 1, name: 'John' };
// Mock the HttpService dependency
httpService.get.mockResolvedValue({ data: userFixture });
database.saveUser.mockResolvedValue(userFixture.id);
const result = await underTest.generateRandomUser();
expect(httpService.get).toHaveBeenCalledWith('/random-user');
expect(database.saveUser).toHaveBeenCalledWith(userFixture);
expect(result).toEqual(userFixture.id);
});
});
Clarifying the .expose()
Behavior
In the setup for sociable tests, when we use .expose()
to include a class like UserApi
as a real dependency, Suites
still mocks its internal dependencies (HttpService
and Database
in this example). This setup allows us to test the
real interactions within the UserApi
class while controlling the behavior of its dependencies. Essentially, this
approach ensures that the UserApi
class operates correctly in conjunction with other real components while maintaining
a controlled testing environment for its interactions.
Exposing Limitations and Anti-Patterns
When using the .expose()
method to make certain classes real in your test environment, it's important to understand
some limitations and potential anti-patterns:
No Retrieval of Exposed Classes from Unit Reference:
-
You cannot retrieve an exposed class from the
unitRef
using.get()
after calling.expose()
. This limitation is by design. The purpose of.expose()
is to make the class a real, non-mocked dependency within the test context, making it part of the "system under test." -
Allowing retrieval of exposed classes from the
unitRef
could lead to undesirable testing practices, such as attempting to on the internal state or behavior of a real class. This contradicts the essence of sociable unit testing, where the goal is to verify real interactions within a controlled environment. -
By restricting access to exposed classes, Suites ensures that the interactions remain consistent and that developers do not inadvertently mock or stub the behavior of real components, preserving the integrity of the sociable testing approach.
Avoid Over-Exposing Dependencies:
-
Over-exposing classes in your test context can lead to complex tests that become difficult to maintain and understand. Sociable unit tests aim to test interactions between a few key classes while maintaining control over others using mocks or stubs.
-
Excessive exposure may introduce unnecessary complexity, reducing the clarity and effectiveness of the test. It’s best to limit exposure to only those classes directly involved in the interaction you wish to test, keeping the test scope focused.
Step 3: Using Suites Mocking API to Define Mock Behavior
Defining final behavior and controlling mocks with .mock().impl()
and .mock().final()
is still possible with
sociable unit tests. Refer to the Suites API section for details
on using these methods.
Next Steps
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. This holistic approach provides a robust foundation for verifying individual components in isolation while also ensuring the reliability of interactions between components.