Solitary tests and Sociable tests
The content here is under the Attribution 4.0 International (CC BY 4.0) license
The different types of interactions between tests are a subject of discussion among practitioners. As the practice of TDD became popular, details of the practice emerged and practitioners discussed how to better tackle different testing situations. One result of these discussions is the distinction between solitary tests and sociable tests. The distinction separates tests that isolate the unit under test using mocks or test doubles from tests that allow the unit to interact with real implementations of its dependencies.
This distinction was articulated by Jay Fields and has been discussed by thought leaders like Martin Fowler. It moves beyond the simplistic “mock everything” or “mock nothing” approaches and provides a nuanced framework for making deliberate choices about test boundaries (Fowler, 2007). Understanding the trade-offs between these two styles can help avoid fragile tests during refactoring.
A talk about solitary and sociable tests
Introduction
Jay Fields introduced solitary and sociable in his book “Working Effectively with Unit Tests” where he distinguishes between solitary unit tests, which isolate a unit using mocks or test doubles, and sociable unit tests, which allow the unit to interact with real implementations of its dependencies. More recently, in a blog post by Manuel Ribeiro the same concept was applied and explored using the GOOS book as the base of the discussion (Rivero, 2025).
For teams applying TDD, the distinction helps decide when to favour collaboration between objects versus isolation. The following table draws a parallel between concepts from GOOS and the ideas in Working Effectively with Unit Tests.
| GOOS Concept | Description in GOOS | How it Relates to Solitary Tests | How it Relates to Sociable Tests |
|---|---|---|---|
| Peer | A collaborator that the object under test communicates with. Communication is often reciprocal, and the object under test depends on the peer’s behavior. | The peer is replaced with a test double (a mock). The test’s purpose is to verify the interaction protocol—that the correct methods were called on the mock with the correct arguments. | The peer is used as a real object. The test does not verify intermediate calls but rather the final outcome of the collaboration between the object and its real peer. |
| Dependency | An object that the unit under test depends on to get data. It is queried for a value but is not told what to do. Communication is one-way. | The dependency is replaced with a test double (a stub). The stub is configured to return a specific, canned value to control the test’s execution path (e.g., providing a specific user object). | The dependency is used as a real object (if it’s a simple, in-memory collaborator). The test validates the system’s behavior using real data provided by the real dependency. |
| Notification | A collaborator that is “told” something but does not return a value. It’s a “fire-and-forget” command sent to a listener or observer at the edge of a component. | The recipient of the notification is replaced with a test double (a mock). The test’s purpose is to verify that the notification command was sent correctly. | The real listener object might be used, but the final boundary (e.g., the network call that sends an email) would be mocked. The test focuses on whether the notification-triggering event occurred correctly. |
Based on this comparison, we see that the attempt to create a boundary and a categorization for practitioners to follow was at the core of the idea. Since “unit” is a relative term, the distinction between solitary and sociable tests provides a guideline for understanding how to test different parts of a system.
Hands on examples
In the following sections, we explore both solitary and sociable tests through practical examples. The goal is to illustrate how each type of test is structured, what it aims to achieve, and the trade-offs involved in using each approach.
Solitary tests: an example
Let’s consider an example of a solitary test for a
UserService class that registers users and validates their email via an EmailValidatorService.
export class EmailValidatorService {
constructor() {
this.allowedDomains = new Set(['corp.com', 'university.edu']);
}
isValid(email) {
if (!email || !email.includes('@')) {
return false;
}
if (email === '@corp.com') {
return false;
}
const domain = email.split('@')[1];
return this.allowedDomains.has(domain);
}
}
Next, consider a UserService that uses this EmailValidatorService to register users:
export class UserService {
constructor(validator) {
this.validator = validator;
}
register(email) {
if (!this.validator.isValid(email)) {
return { status: 'FAILURE', reason: 'Invalid or disallowed email domain.' };
}
console.log(`Registering user: ${email}`);
return { status: 'SUCCESS' };
}
}
A solitary test is designed to verify the behavior of a single unit—typically a single class—in isolation from its dependencies. To achieve this isolation, collaborators that the unit under test communicates with are replaced with test doubles (such as mocks or stubs). The primary goal of a solitary test is to ensure that if the test fails, the failure is located within that specific unit and not in one of its dependencies. This shortens the feedback loop.
import { describe, expect, test, vi, beforeEach } from 'vitest'
import { UserService } from "./UserService";
describe('UserService (Solitary Tests)', () => {
let mockValidator;
let userService;
beforeEach(() => {
mockValidator = {
isValid: vi.fn(),
};
userService = new UserService(mockValidator);
});
test('should call the notifier and return success when email is valid', () => {
mockValidator.isValid.mockReturnValue(true);
const email = 'test@corp.com';
const result = userService.register(email);
expect(mockValidator.isValid).toHaveBeenCalledWith(email);
expect(result).toEqual({ status: 'SUCCESS' });
});
test('should NOT call the notifier and return failure when email is invalid', () => {
mockValidator.isValid.mockReturnValue(false);
const email = 'test@gmail.com';
const result = userService.register(email);
expect(mockValidator.isValid).toHaveBeenCalledWith(email);
expect(result).toEqual({ status: 'FAILURE', reason: 'Invalid or disallowed email domain.' });
});
});
Using this approach allows us to focus on the behavior of the UserService class without worrying about the
implementation details of the EmailValidatorService. The test verifies that the UserService correctly calls the isValid
method of the EmailValidatorService with the expected argument.
- Failures are pinpointed to the specific unit under test, making debugging faster.
- By replacing potentially slow collaborators, tests run faster. For example, isolating a database call with a test double can significantly speed up tests.
However, solitary tests come with trade-offs as well:
- The test passes only if the mock’s behavior accurately reflects the real collaborator’s behavior. If the real collaborator changes and the mock is not updated, the tests may provide a false sense of security.
- These tests are often coupled to implementation details of how units collaborate. If you refactor the internal communication between two objects without changing the system’s overall behavior, solitary tests might break, becoming “change-detector” tests. This is a core point in the discussion around are true mocks evil?. Managing mocks and keeping them in sync with real collaborators can become a significant maintenance burden. IDEs have become more sophisticated in helping developers keep mocks up to date with their real collaborators. When it gets to it, design plays a crucial role in minimizing these issues, the discussion the focuses on the behaviour and what we are trying to test(Janzen & Saiedian, 2005)
As you might have noticed, when we isolate the verification service, we also need to write tests for it:
import { describe, expect, test } from 'vitest'
import {EmailValidatorService} from "./EmailValidatorService";
describe('EmailValidatorService', () => {
const validator = new EmailValidatorService();
describe('when validating emails with allowed domains', () => {
test('should return true for an email from "corp.com"', () => {
const email = 'employee@corp.com';
expect(validator.isValid(email)).toBe(true);
});
test('should return true for an email from "university.edu"', () => {
const email = 'student@university.edu';
expect(validator.isValid(email)).toBe(true);
});
});
describe('when validating emails with disallowed domains', () => {
test('should return false for an email from a common public domain', () => {
const email = 'user@gmail.com';
expect(validator.isValid(email)).toBe(false);
});
test('should return false for an email from another unlisted domain', () => {
const email = 'contact@another-company.org';
expect(validator.isValid(email)).toBe(false);
});
});
describe('when validating malformed or invalid input', () => {
test('should return false for an email without an "@" symbol', () => {
const email = 'invalid-email.com';
expect(validator.isValid(email)).toBe(false);
});
test('should return false for an email that is just a domain', () => {
const email = '@corp.com';
expect(validator.isValid(email)).toBe(false);
});
test('should return false for an empty string', () => {
const email = '';
expect(validator.isValid(email)).toBe(false);
});
test('should return false for null input', () => {
const email = null;
expect(validator.isValid(email)).toBe(false);
});
test('should return false for undefined input', () => {
const email = undefined;
expect(validator.isValid(email)).toBe(false);
});
});
});
Sociable tests: an example
A sociable test verifies the behavior of a module by allowing it to collaborate with its real dependencies. The “unit” being tested is therefore not a single class, but a small graph of interconnected objects that work together to fulfil a piece of functionality.
import {EmailValidatorService} from "./EmailValidatorService";
export class UserService {
constructor() {
this.validator = new EmailValidatorService();
}
register(email) {
if (!this.validator.isValid(email)) {
return { status: 'FAILURE', reason: 'Invalid or disallowed email domain.' };
}
console.log(`Registering user: ${email}`);
return { status: 'SUCCESS' };
}
}
Sociable tests allow us to verify that the UserService interacts correctly with its real dependencies, such as
the EmailValidatorService. The test checks that the registration process works as expected, including the verification logic.
import { describe, expect, test, vi, beforeEach } from 'vitest'
import { UserService } from "./UserService";
import {EmailValidatorService} from "./EmailValidatorService";
describe('UserService (Sociable Tests)', () => {
let userService;
beforeEach(() => {
userService = new UserService(new EmailValidatorService());
});
test('register user successfully with valid email', () => {
const email = 'test@corp.com';
const result = userService.register(email);
expect(result).toEqual({ status: 'SUCCESS' });
});
test('should NOT register user when email is invalid', () => {
const email = 'test@gmail.com';
const result = userService.register(email);
expect(result).toEqual({ status: 'FAILURE', reason: 'Invalid or disallowed email domain.' });
});
test.each([
[{ status: 'SUCCESS' }, 'employee@corp.com'],
[{ status: 'SUCCESS' }, 'student@university.edu'],
[{ reason: "Invalid or disallowed email domain.",status: 'FAILURE' }, 'user@gmail.com'],
[{ reason: "Invalid or disallowed email domain.", status: 'FAILURE' }, 'contact@another-company.org'],
[{ reason: "Invalid or disallowed email domain.",status: 'FAILURE' }, 'invalid-email.com'],
[{ reason: "Invalid or disallowed email domain.",status: 'FAILURE' }, '@corp.com'],
[{ reason: "Invalid or disallowed email domain.",status: 'FAILURE' }, ''],
[{ reason: "Invalid or disallowed email domain.",status: 'FAILURE' }, null],
[{ reason: "Invalid or disallowed email domain.",status: 'FAILURE' }, undefined],
])('should return %s for an email from "corp.com"', (expected, email) => {
const result = userService.register(email);
expect(result).toEqual(expected);
});
});
The test verifies how a cluster of objects actually works together, catching integration bugs between them. This is also referred to as internal objects(Rivero, 2025). This approach focuses on the long-term maintainability of the tests. As long as the public API of the component under test remains the same, you can refactor the internal collaborations between its objects without breaking the test.
However, sociable tests also have trade-offs:
When a sociable test fails, the bug could be in the main unit under test or in any of its internal collaborators, requiring more debugging to pinpoint the source. Also, instantiating a real object graph for a test can sometimes be more complex than creating a few mocks, especially when objects have their own dependencies that need to be constructed.
I recall facing this issue when I worked on a project with a complex object graph and many dependencies. It was a pagination object that had to interact with a database and other dependencies required to construct the object graph.
React example
In the context of React, sociable tests often involve testing components with their real dependencies, such as other components. json-tool is a good example of a sociable test in React. It allows you to test components together with a few test doubles, focusing on the component’s behavior without worrying about its dependencies.
The project hosts a test utility that creates the application to be tested by Testing Library and Jest. The utility builds the dependency graph in the same way React uses it in the browser (except for the router).
Notes on the practice
The choice between solitary and sociable tests is not binary; it depends on the context and the specific requirements of the system being developed. Often, teams use a mix of both approaches: solitary tests for isolated units and sociable tests for larger components or modules that require real interactions. My personal take depends on the stack at hand, but a common rule I use is to favour refactoring over premature isolation.
The role of design
A dedicated space for design
I have dedicated a page for software design, as such, I will skip the details here and focus on the role of design in the context of solitary and sociable tests.
The choice between solitary and sociable testing is a design decision that reflects and reinforces a particular architectural style. The ease or difficulty of writing a test is often a direct signal about the quality of the underlying system. In TDD, we use this signal not just to validate our code, but to actively shape it.
Your test suite is a living document that describes your system’s design. The choice between solitary and sociable tests reflects what you choose to emphasize in that design. Solitary tests express a design built around a web of explicit, fine-grained and interactions sociable tests express a design built from cohesive, self-contained behavioral components.
They are different lenses through which to view and shape your code. The most mature approach is to understand the design philosophy that each testing style promotes and to choose the style that best aligns with the architectural goals of the specific component you are building. The difficulty of writing a test is not an obstacle; it is valuable feedback on the clarity and quality of your design.
Resources
References
- Fowler, M. (2007). Mocks Aren’t Stubs. https://martinfowler.com/articles/mocksArentStubs.html
- Rivero, M. (2025). The class is not the unit in the London school style of TDD. Codesai Blog. https://codesai.com/posts/2025/03/mockist-tdd-unit-not-the-class
- Janzen, D., & Saiedian, H. (2005). Test-driven development concepts, taxonomy, and future direction. Computer, 38(9), 43–50.
Changelog
- Oct 15, 2024 - Fixed minor typos and formatting issues and references (Janzen & Saiedian, 2005)