The Problem We're Solving
Ever come across a utils.ts
file that's more of a everything-but-the-kitchen-sink.ts
file? We've all been there. It starts innocently enough with a small helper function, but over time, it becomes a dumping ground. Let's imagine a function called processUserData
that handles everything for user registration. It validates the user's data, saves it to the database, and then sends a welcome email.
While this might seem efficient, it's a nightmare to test. How do you write a test for this one function? You'd have to mock the database to prevent it from saving actual data and mock the email service to stop it from sending real emails. It’s like trying to test if a car's horn works, but you're forced to test the engine, the transmission, and the radio at the same time. This complexity is a huge red flag.
The TDD Mindset: Write a Failing Test First
This is where Test-Driven Development (TDD) comes in. The core idea is simple: before you write any code, you write a test for it. The test should fail initially because the code it's testing doesn't exist yet. This forces you to think about what you want your function to do in a very specific way.
Let's try to write a test for our processUserData
function:
// processUserData.test.ts
import { processUserData } from './utils';
import { saveUserToDatabase } from './database';
import { sendWelcomeEmail } from './emailService';
// Mocking the external functions so they don't actually run
jest.mock('./database');
jest.mock('./emailService');
describe('processUserData', () => {
it('should process user data, save to the database, and send a welcome email', () => {
const userData = { name: 'Alice', email: 'alice@example.com' };
// This is the part that gets messy
const result = processUserData(userData);
expect(result).toBe(true);
expect(saveUserToDatabase).toHaveBeenCalledWith(userData);
expect(sendWelcomeEmail).toHaveBeenCalledWith(userData.email);
});
});
This test is already complicated. It's trying to verify three different things at once: that the function returns a value, that it calls a database function, and that it calls an email function. The test itself is a signal that our function is doing too much. The test is screaming at us to break the function apart.
The "Aha!" Moment: My Own Real-World Example
Let me share my own experience with this. I was working on a project with a massive utils.ts
file. I wanted to test just one simple helper function within it, but I discovered it was impossible to do so in isolation. The function I wanted to test was using other functions from the same file, and because they were all in one big file, I couldn't mock them.
I couldn't write a clean, simple test. The test kept getting bloated and complicated, and I found myself in a testing dead end. This frustration was my "aha!" moment. I realized that my inability to mock the other functions was a clear sign that the design was flawed. The functions were too tightly coupled and doing more than they should. The test was telling me: "You need to separate these things."
The solution was to split the file. I moved each function into its own file or into a file with a very specific, related group of functions. By doing this, I made the unrelated functions easy to mock, and suddenly, my tests were simple, clear, and isolated. I had applied the Single Responsibility Principle without even knowing its name. I didn't do it to follow a rule; I did it to make my life easier and make testing possible.
Principles as Solutions, Not Starting Points
This is a key point I want you to remember. Software principles and patterns are not rules we blindly follow just because they exist. They were created to solve problems developers faced. They are solutions that emerged from real-world pain.
You don't need to memorize a list of principles before you start coding. Instead, it's good to be aware of them. When you run into a problem—like a test that's too difficult to write, or a function that's getting too long—you might realize that the problem is a classic one. By searching for a solution, you'll likely find one of these principles, and it will be easy to understand because you've already experienced the pain it's designed to fix.
So, don't worry about being an expert on every rule. Instead, focus on two simple goals: make your code easy to understand and easy to test. Those two goals will naturally guide you toward good design principles, just like they did for me.
Refactor and Separate Responsibilities
The revelation is this: a difficult-to-write test is a sign that your code's design is flawed. The solution is to separate the concerns. Let's break down our massive function into three smaller, more focused functions.
Here is what our new, refactored utils.ts
file looks like:
// new utils.ts
export const isValidUserData = (data: any): boolean => {
if (!data || !data.email) {
return false;
}
// A simple email validation check
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email);
};
export const saveUserToDatabase = async (data: any): Promise<boolean> => {
console.log(`Saving user ${data.email} to the database...`);
// Pretend this function interacts with a real database
return true;
};
export const sendWelcomeEmail = async (email: string): Promise<boolean> => {
console.log(`Sending welcome email to ${email}...`);
// Pretend this function sends a real email
return true;
};
Now, look at how easy it is to test each of these functions individually.
// isValidUserData.test.ts
import { isValidUserData } from './utils';
describe('isValidUserData', () => {
it('should return true for a valid email', () => {
expect(isValidUserData({ email: 'test@example.com' })).toBe(true);
});
it('should return false for an invalid email', () => {
expect(isValidUserData({ email: 'invalid-email' })).toBe(false);
});
});
This is so much simpler! We don't need any complex mocking. The tests are focused and clear, and they only care about one specific piece of functionality. By making our code easy to test, we have naturally applied a key programming principle: each function should do one thing and do it well.
The Orchestrator Pattern
Now that we have our small, focused functions, we can create a new function that coordinates them. We'll call this the "orchestrator" function. It doesn't do any of the heavy lifting itself; it simply calls the other functions in the correct order.
// orchestrator.ts
import { isValidUserData, saveUserToDatabase, sendWelcomeEmail } from './utils';
export const processUserData = async (data: any): Promise<boolean> => {
if (!isValidUserData(data)) {
console.log('Invalid user data provided.');
return false;
}
await saveUserToDatabase(data);
await sendWelcomeEmail(data.email);
return true;
};
This is much cleaner. Our processUserData
function is now a manager that delegates tasks to specialists. It asks isValidUserData
to validate, tells saveUserToDatabase
to save, and instructs sendWelcomeEmail
to send the email. This pattern makes the overall process much easier to understand, debug, and maintain.
Finding the Balance: When to Stop and Why
A word of caution: this doesn't mean you should break down every single line of code into its own function. Over-engineering can lead to a confusing project structure with hundreds of tiny files. The true measure of a good design is its ease of testing.
- The Golden Rule: If a function or component is still easy to test and read, it likely doesn't need to be broken down further.
- The "Why": The goal isn't to create more files; it's to create code that is more maintainable, readable, and flexible. When you need to fix a bug or add a new feature, you should only have to make changes in one place. If you can change the email format without touching the database saving logic, you've found a good balance.
Summary
Congratulations! You've just applied a core software design principle. You started with a messy function that was difficult to test, used a failing test to signal a design flaw, and then refactored the code into smaller, more manageable pieces.
You've discovered that TDD is more than just a testing technique; it's a powerful guide that naturally pushes you toward better, more focused designs. By focusing on writing easy-to-test code, you will find that your projects become easier to maintain, understand, and grow. Keep this mindset, and you'll be building more robust and flexible applications in no time.