| title | Write Tests That Adapt to Application Code | ||||
|---|---|---|---|---|---|
| id | write-tests-that-adapt-to-application-code | ||||
| skillLevel | intermediate | ||||
| applicationPatternId | testing | ||||
| summary | A cardinal rule of testing: Tests must adapt to the application's interface, not the other way around. Never modify application code solely to make a test pass. | ||||
| tags |
|
||||
| rule |
|
||||
| related |
|
||||
| author | Paul Philp | ||||
| lessonOrder | 4 |
Tests are secondary artifacts that serve to validate the application. The application's code and interfaces are the source of truth. When a test fails, fix the test's logic or setup, not the production code.
Treating application code as immutable during testing prevents the introduction of bugs and false test confidence. The goal of a test is to verify real-world behavior; changing that behavior to suit the test invalidates its purpose.
import { Effect } from "effect";
// Define our types
interface User {
id: number;
name: string;
}
class NotFoundError extends Error {
readonly _tag = "NotFoundError";
constructor(readonly id: number) {
super(`User ${id} not found`);
}
}
// Define database service interface
interface DatabaseServiceApi {
getUserById: (id: number) => Effect.Effect<User, NotFoundError>;
}
// Implement the service with mock data
class DatabaseService extends Effect.Service<DatabaseService>()(
"DatabaseService",
{
sync: () => ({
getUserById: (id: number) => {
// Simulate database lookup
if (id === 404) {
return Effect.fail(new NotFoundError(id));
}
return Effect.succeed({ id, name: `User ${id}` });
},
}),
}
) {}
// Test service implementation for testing
class TestDatabaseService extends Effect.Service<TestDatabaseService>()(
"TestDatabaseService",
{
sync: () => ({
getUserById: (id: number) => {
// Test data with predictable responses
const testUsers = [
{ id: 1, name: "Test User 1" },
{ id: 2, name: "Test User 2" },
{ id: 123, name: "User 123" },
];
const user = testUsers.find((u) => u.id === id);
if (user) {
return Effect.succeed(user);
}
return Effect.fail(new NotFoundError(id));
},
}),
}
) {}
// Business logic that uses the database service
const getUserWithFallback = (id: number) =>
Effect.gen(function* () {
const db = yield* DatabaseService;
return yield* Effect.gen(function* () {
const user = yield* db.getUserById(id);
return user;
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
if (error instanceof NotFoundError) {
yield* Effect.logInfo(`User ${id} not found, using fallback`);
return { id, name: `Fallback User ${id}` };
}
return yield* Effect.fail(error);
})
)
);
});
// Create a program that demonstrates the service
const program = Effect.gen(function* () {
yield* Effect.logInfo(
"=== Writing Tests that Adapt to Application Code Demo ==="
);
const db = yield* DatabaseService;
// Example 1: Successful user lookup
yield* Effect.logInfo("\n1. Looking up existing user 123...");
const user = yield* Effect.gen(function* () {
try {
return yield* db.getUserById(123);
} catch (error) {
yield* Effect.logError(
`Failed to get user: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: -1, name: "Error" };
}
});
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
// Example 2: Handle non-existent user with proper error handling
yield* Effect.logInfo("\n2. Looking up non-existent user 404...");
const notFoundUser = yield* Effect.gen(function* () {
try {
return yield* db.getUserById(404);
} catch (error) {
if (error instanceof NotFoundError) {
yield* Effect.logInfo(
`✅ Properly handled NotFoundError: ${error.message}`
);
return { id: 404, name: "Not Found" };
}
yield* Effect.logError(
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: -1, name: "Error" };
}
});
yield* Effect.logInfo(`Result: ${JSON.stringify(notFoundUser)}`);
// Example 3: Business logic with fallback
yield* Effect.logInfo("\n3. Business logic with fallback for missing user:");
const userWithFallback = yield* getUserWithFallback(999);
yield* Effect.logInfo(
`User with fallback: ${JSON.stringify(userWithFallback)}`
);
// Example 4: Testing with different service implementation
yield* Effect.logInfo("\n4. Testing with test service implementation:");
yield* Effect.provide(
Effect.gen(function* () {
const testDb = yield* TestDatabaseService;
// Test existing user
const testUser1 = yield* Effect.gen(function* () {
try {
return yield* testDb.getUserById(1);
} catch (error) {
yield* Effect.logError(
`Test failed: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: -1, name: "Test Error" };
}
});
yield* Effect.logInfo(`Test user 1: ${JSON.stringify(testUser1)}`);
// Test non-existing user
const testUser404 = yield* Effect.gen(function* () {
try {
return yield* testDb.getUserById(404);
} catch (error) {
yield* Effect.logInfo(
`✅ Test service properly threw NotFoundError: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: 404, name: "Test Not Found" };
}
});
yield* Effect.logInfo(`Test result: ${JSON.stringify(testUser404)}`);
}),
TestDatabaseService.Default
);
yield* Effect.logInfo(
"\n✅ Tests that adapt to application code demonstration completed!"
);
yield* Effect.logInfo(
"The same business logic works with different service implementations!"
);
});
// Run the program with the default database service
Effect.runPromise(
Effect.provide(program, DatabaseService.Default) as Effect.Effect<
void,
never,
never
>
);Explanation:
Tests should reflect the real interface and behavior of your code, not force changes to it.
Any action where the test dictates a change to the application code. Do not modify a service file to add a method just because a test needs it. If a test fails, fix the test.