DEV Community

Cover image for Your-Tests-Are-Slow-and-Brittle-Youre-Testing-the-Wrong-Thing
member_06022d8b
member_06022d8b

Posted on

Your-Tests-Are-Slow-and-Brittle-Youre-Testing-the-Wrong-Thing

GitHub Home

Your Tests Are Slow and Brittle? You're Testing the Wrong Thing! 🧪➡️✅

"We should write more tests."

In every technical meeting, this sentence is repeated like a sacred mantra. Everyone nods in agreement, everyone knows this is the "right" thing to do. But back at their desks, the expression on many faces turns to one of pain. 😫

Why? Because in many projects, writing tests is a chore. The tests run as slow as a turtle; a full test suite takes long enough for you to brew three cups of coffee. ☕️ The test code itself is more complex and harder to understand than the business logic. And the deadliest part: these tests are incredibly "brittle." You change a CSS class name in the frontend, or add a field to a JSON response, and hundreds of tests mysteriously fail. 💔

If you can relate to these scenarios, then as an old-timer, I want to let you in on a secret: the reason your tests are so bad is probably not the tests themselves, but your application architecture, which makes testing extremely difficult. And one of the core values of a good framework is to guide you toward building a "testable" architecture.

The "Wrong" Way to Test: Obsessing Over Testing Everything Through the UI and HTTP

Many developers, especially those new to the field, understand "testing" as "simulating user behavior." So, they write a large number of tests to automate these actions:

  • Start a full web server.
  • (For the backend) Send a real HTTP request to an endpoint.
  • (For the frontend) Launch a browser, find a button, and click it.
  • Assert whether the returned HTTP status code, JSON content, or text on the page meets expectations.

In the Node.js world, using a library like supertest to test an Express application is a classic example of this thinking.

// An example of testing via HTTP
const request = require('supertest');
const app = require('../app'); // Your entire Express app

describe('POST /users', () => {
  it('should create a new user', async () => {
    const response = await request(app)
      .post('/users')
      .send({ username: 'test', password: 'password' });

    expect(response.statusCode).toBe(201);
    expect(response.body.username).toBe('test');
  });
});
Enter fullscreen mode Exit fullscreen mode

We call these "end-to-end tests" or "integration tests." Are they useful? Of course! They can verify that all parts of the system work together correctly. But if your testing strategy relies only on this type of test, you are making a huge mistake. Why?

  1. Slow! Slow! Slow! Starting a server, establishing a network connection, serializing and deserializing JSON... every step takes time. A single test can take tens or even hundreds of milliseconds. When you have hundreds or thousands of tests, the total time can stretch to several minutes or longer. This severely slows down your development feedback loop.
  2. Brittle! They are too tightly coupled to external details (UI structure, API contract). If the API response adds a field, the test might fail. These tests care about the "presentation," not the "internal logic."
  3. Difficult to Cover Edge Cases! Your core business logic may have many branches and exception conditions. For example, "What happens if the database goes down right after a user is created but before the welcome email is sent?" It's almost impossible to accurately simulate such a scenario with an HTTP request. You can't just unplug the database server to run a test, can you?

The "Right" Way to Test: The Pyramid and Decoupled Layers

A proper testing strategy should be like a pyramid. At the bottom are a large number of fast, reliable unit tests. In the middle are a smaller number of integration tests. At the very top are a tiny number of end-to-end tests.

The Test Pyramid

The key to achieving this pyramid lies in the layered architecture we discussed in the previous article. In a well-designed application, the core business logic should be completely decoupled from the outside world (like the web framework, database, etc.).

This is where the power of the Hyperlane blueprint comes in. It encourages you to place your most valuable and complex logic in the service and domain layers. And these layers are pure Rust code, independent of any web details. Therefore, they can be subjected to the purest and fastest unit tests.

Unit Testing a Hyperlane Service: An Experience of Speed and Power ⚡️

Let's imagine a UserService responsible for handling user registration logic. Following Hyperlane's architectural advice, it might look like this:

// in src/app/service/user_service.rs

// UserService depends on a trait (an interface), not a concrete database implementation
pub struct UserService {
    pub user_repo: Arc<dyn UserRepository>,
}

impl UserService {
    pub fn register_user(&self, username: &str) -> Result<User, Error> {
        // Core logic: check if the username already exists
        if self.user_repo.find_by_username(username).is_some() {
            return Err(Error::UsernameExists);
        }

        // ... other logic, like checking password strength ...

        let user = User::new(username);
        self.user_repo.save(&user)?;
        Ok(user)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, how do we test the register_user function? We don't need to start a server, nor do we need to connect to a real database. We just need to test the logic itself. We can use a "test double," usually a "mock object," to play the role of the UserRepository.

In Rust, we can easily create mock objects using a library like mockall.

// in tests/user_service_test.rs

#[cfg(test)]
mod tests {
    use super::*;
    use mockall::*;

    // Create a mock implementation of UserRepository
    #[automock]
    trait UserRepository {
        fn find_by_username(&self, username: &str) -> Option<User>;
        fn save(&self, user: &User) -> Result<(), DbError>;
    }

    #[test]
    fn test_register_user_fails_if_username_exists() {
        // 1. Arrange: Create a mock repository
        let mut mock_repo = MockUserRepository::new();

        // 2. Expect: We expect the `find_by_username` method
        //    to be called once with the argument "testuser",
        //    and when it is, it should return an existing user.
        mock_repo.expect_find_by_username()
                 .with(predicate::eq("testuser"))
                 .times(1)
                 .returning(|_| Some(User::new("testuser")));

        // Inject the mock object into our service
        let user_service = UserService { user_repo: Arc::new(mock_repo) };

        // 3. Act: Call the function we want to test
        let result = user_service.register_user("testuser");

        // 4. Assert: We expect the result to be a "UsernameExists" error
        assert!(matches!(result, Err(Error::UsernameExists)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Savor this test code for a moment. What makes it beautiful?

  • Fast: It runs in memory and involves no I/O. It executes in milliseconds or less. You can have thousands of such tests and get feedback in seconds.
  • Precise: It tests exactly the business logic we care about—"registration should fail if the username exists." It is not affected by any external factors.
  • Powerful: We can easily simulate various edge cases. What if the database connection fails? Just have mock_repo.expect_save() return an Err(DbError::ConnectionFailed). This level of control is unmatched by end-to-end tests.

What About the controller?

Of course, we still need a few integration tests to ensure that the controller layer's routes are correctly wired to the service layer's methods, and that JSON serialization and deserialization work as expected. But since all the complex logic is already covered by unit tests in the service layer, the controller tests can be very simple "happy path" tests. You only need a handful of them.

Good Architecture Leads to Good Tests

Now, you should understand my point. The biggest difference between an easily testable application and one that is difficult to test lies in the architecture.

What causes you pain is never the testing itself, but the "big ball of mud" architecture that prevents you from performing independent, rapid unit tests on your business logic. A good web framework will, through its design philosophy and project templates, guide you from the very beginning onto a bright path of "testability."

It will encourage you to decouple your core logic from the web layer, and to use dependency injection and interfaces (traits). It allows you to spend 90% of your effort writing those lightning-fast, rock-solid unit tests. When testing is no longer a burden but a fast, reliable feedback tool, you will genuinely start to love it. ❤️

So, the next time you evaluate a framework, don't just ask, "Is it cool to use?" Also ask, "Is the code written with it easy to test?" Because only a framework that helps you easily write good tests can ultimately help you build a truly high-quality, highly reliable application. ✅

GitHub Home

Top comments (0)