| Term | Scope | Mocks |
|---|---|---|
| Unit Test (Solitary) | One method / one class | All direct dependencies. |
| Unit Test (Sociable) | One feature / multiple classes | Only external systems (DB/API). |
| Integration Test | System + external | No mocks (or very few). |
Example: we want to unit test the PaymentService class.
- The
PaymentServicedepends on theWalletService, which is an internal class. - The
WalletServicedepends onExternalApiClient.
- 😎 Amine: To unit test
PaymentService, I will mockWalletService. - 😤 Gab: I prefer to test
PaymentServicewith a realWalletServiceimplementation, but I will mock the external API.
In modern software engineering, there are two main schools of thought about unit testing:
- Solitary (London School): Mocks every direct dependency. When people say "unit test", many developers mean a "solitary" unit test.
- Sociable (Detroit/Chicago School): Uses real classes for everything inside your application boundary, and only mocks the "out-of-process" dependencies (DB, APIs).
- You can still call it a "Unit Test" under the Detroit School definition, as long as it stays in-memory and runs fast.
- Or call it a "Component Test": your test covers the entire feature logic, from the Service down to the Repository. You are testing the "component" (your business logic) as a single unit, treating the database and external API as the only "outside" worlds.
- How about calling it an "Integration Test"? Traditionally, if a test touches more than one class, many developers call it an integration test. However, "integration test" often implies you are testing how your code integrates with the real database or real API. Since you are still mocking those, the terms "component test" or "sociable unit test" are usually more accurate.
| Feature | Solitary (Mocking Everything) | Sociable (Mocking only Externals) |
|---|---|---|
| Refactoring | High friction When internal methods change, tests break easily. |
Low friction Tests stay green as long as the result is correct. |
| Bug Detection | Low Misses bugs in how classes talk to each other. Low protection against regressions caused by refactoring. |
High Catches integration issues between your classes. Strong regression prevention during refactoring. |
| Edge cases | Setup is simple. Can test whatever edge cases (or combinations) you can imagine. |
Setup is more complex. Usually tests only critical paths or realistic edge cases. |
| Scaling | Scales with interactions. The more interactions between classes change, the more tests need to be added or adapted. |
Scales with behavior. The more behavior changes, the more tests need to be added or adapted. |
Many senior developers (notably Martin Fowler and Ian Cooper) recommend sociable unit tests, especially if you have many solitary unit tests to maintain.
- Your 1000+ solitary unit tests don't give you enough confidence.
- They feel useless (or low value).
- They protect implementation details more than the feature outcome.
- Adding more tests feels like the work of a code-generation robot just to hit 100% test coverage in SonarQube.
- You want to increase confidence before a heavy refactoring.
Then "component tests" (or sociable unit tests) might be the answer. The initial setup for component tests might scare you at first, but it becomes incremental over time. In the long run, the maintainability cost of component tests and solitary unit tests is similar in my experience.
Read also:
- https://martinfowler.com/bliki/UnitTest.html
- https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications
- Testing Pyramid
Follow both schools:
- For complex functions with a lot of algorithmic/computational logic ⇒ solitary unit tests.
- For functions with little logic that mostly call other functions ⇒ sociable unit tests.
If you're hesitating, I would prioritize: E2E tests (integration tests) > component tests > (solitary) unit tests.
- Favor E2E tests as long as the "cost" is reasonable (cost = effort to set up + development + maintenance).
- If certain scenarios are too "expensive" for E2E tests ⇒ fall back to component tests.
- If certain scenarios are too "expensive" for component tests ⇒ fall back to (solitary) unit tests.
Note: Component tests often replicate E2E test cases, and that's not redundant—think of them as "cheap and fast E2E tests" that are useful during development.
A test method usually has three parts: Arrange, Act, Assert.
You might write 10 test cases that are mostly similar, where each test case has slightly different Arrange or Assert steps. => so you try to create shared Arrange/Assert blocks that can be reused across tests.
Over time, the situation can become spaghetti:
//Used in Test1, Test2
void Arrange1(bool cond) {
if (cond) {..}
}
//Used in Test2, Test3
fun Arrange2() {
}
//Used in Test1, Test3
fun Assert1() {}
//Used in Test1, Test6
fun Assert2(bool cond) {}
- Update "Test1"
- => Need to slightly update "Arrange1" and "Assert2"
- => It makes "Test2" and "Test6" fail...
Use the Builder pattern: create small reusable Arrange/Assert units instead of one big Arrange/Assert block, so different tests can compose them as needed.
void TestCase() {
// Arrange database mock
TestDataGenerator dataGenerator = new(_dbContext);
dataGenerator.GenerateEntityX();
dataGenerator.GenerateEntityY();
dataGenerator.GenerateRowA();
dataGenerator.GenerateEntityXY();
dataGenerator.GenerateEntityX2();
dataGenerator.GenerateRowAB();
await _dbContext.SaveChangesAsync();
// Arrange SomeApi mock
SomeApiFakeResponseBuilder SomeApiResponseBuilder = new(_services, _logger);
SomeApiResponseBuilder
.GetEndpoint1_Success()
.PostEndpoint2_Success()
.PutEndpoint3_InternalError()
.GetEndpoint4_NotFound()
.Build();
}
Think of these Arrange/Assert units as "Atoms", you can compose them to form bigger Assert/Arrange block as "Molecule".
- The "molecule" must to be scoped in a Single Test Class.
- The "molecule" should make sense, they should have a clear name. For eg: "Given_Order_With_OutOfStock_Product()".