Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 40

Testing Redis Implementations

Recommended resources

Hello! Welcome back to our journey of building a production-grade rate limiter.

In the last lesson, you did some excellent architectural work. You took the powerful Redis and Lua-based algorithms we developed and encapsulated them behind our RateLimiter interface. This application of the Strategy pattern was a crucial step, making our system clean, extensible, and capable of switching between different rate-limiting algorithms at runtime.

However, having complex distributed logic is only half the battle. How can we be confident that our RedisSlidingWindowRateLimiter and RedisTokenBucketRateLimiter behave exactly as designed, especially considering the nuances of time, state, and atomicity? The answer is through rigorous, automated testing.

Your learning outcome for this lesson:

By the end of this session, you will be able to write unit tests verifying the behavior of the Redis-based implementations. This will ensure your distributed algorithms are correct, reliable, and ready for the challenges of a production environment.



1. The Challenge of Testing Distributed Components

When you were testing the in-memory implementations, the process was self-contained. For our Redis-based strategies, we have an external dependency: the Redis server.

We could mock the RedisTemplate and its interactions, but that wouldn't be a true test. Mocking would only verify that our Java code calls the right methods; it wouldn't confirm that our Lua script executes correctly or that the state in Redis is managed properly.

To solve this, we will use Testcontainers, a brilliant Java library that lets us programmatically control disposable, lightweight Docker containers. For our tests, we'll spin up a genuine Redis container, run our tests against it, and then discard it. This gives us the best of both worlds: the isolation of unit tests and the high fidelity of testing against a real Redis instance.

To understand the concept and benefits of Testcontainers, please watch the first part of the following talk.

Nikolay Kuznetsov - Integration and end-to-end testing with TestContainers and JUnit 5

In this video from Devoxx, Nikolay Kuznetsov introduces Testcontainers. This section explains what it is, its advantages over traditional testing methods, and its core features like automatic environment discovery and container cleanup.

Watch from the beginning until the 14:45 mark. Focus on understanding why Testcontainers is a powerful tool for integration testing and how it manages container lifecycles, port randomization, and cleanup.



2. Setting Up the Test Environment with JUnit 5 and Testcontainers

To get started, you'll need to add the necessary testing dependencies to your pom.xml. Since you are using Spring Boot, spring-boot-starter-test already provides JUnit 5, Mockito, and AssertJ. You just need to add the Testcontainers dependency for Redis.

Token Bucket Rate Limiter (Redis & Java)

The following article, 'Token Bucket Rate Limiter (Redis & Java),' provides a clear guide on the dependencies needed. We will follow a similar setup.

Review the 'Adding Dependencies' section to see the required Maven dependencies for JUnit 5, Testcontainers for Redis, and AssertJ. Add the testcontainers-redis dependency to your project's pom.xml.

Now, let's structure our test class. We will write a test for RedisTokenBucketRateLimiter. The key steps are:

  1. Annotate the test class with @SpringBootTest to load the Spring context and @Testcontainers to enable Testcontainers support.
  2. Declare a RedisContainer instance as a static field and annotate it with @Container. This tells JUnit to manage the container's lifecycle.
  3. Use @DynamicPropertySource to dynamically override the Redis connection properties in your Spring application.yml with the actual host and port of the running container.
  4. Inject your RedisTokenBucketRateLimiter and RedisTemplate beans to use in the tests.
  5. Use a method annotated with @BeforeEach to execute redisTemplate.getConnectionFactory().getConnection().flushAll() to ensure each test starts with a clean Redis database, preventing tests from interfering with each other.

Here is a typical structure for a Spring Boot test using Testcontainers:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class RedisTokenBucketRateLimiterTest {

    @Container
    public static GenericContainer<?> redisContainer = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    @Autowired
    private RedisTokenBucketRateLimiter rateLimiter; // Assuming this is your bean

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // This method dynamically sets the Redis properties for the Spring context
    @DynamicPropertySource
    static void redisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.redis.host", redisContainer::getHost);
        registry.add("spring.redis.port", () -> redisContainer.getMappedPort(6379));
    }

    @BeforeEach
    void setUp() {
        // Ensure a clean slate for each test
        redisTemplate.getConnectionFactory().getConnection().flushAll();
    }
    
    // ... your test methods will go here ...
}


3. Writing Verification Tests

With the setup complete, we can now write tests to verify the behavior of our Redis-based rate limiters. We'll test the most critical scenarios for both the Token Bucket and Sliding Window algorithms. The articles by Raphael De Lio provide excellent, practical examples of the test cases we need.

A. Testing the RedisTokenBucketRateLimiter

Let's write tests to confirm our token bucket implementation is correct. We will use AssertJ for readable assertions (assertThat(...).isTrue()).

The article below provides the exact test cases we need to implement.

Token Bucket Rate Limiter (Redis & Java)

The 'Token Bucket Rate Limiter (Redis & Java)' article contains a series of well-structured unit tests. We will use them as a blueprint to verify our own implementation.

Read through the test methods provided in these sections. For each test, understand its purpose, then implement a similar test in your own RedisTokenBucketRateLimiterTest class. The key scenarios to cover are: shouldAllowRequestsWithinBucketCapacity: Verifies that initial requests up to the bucket's capacity are allowed. shouldDenyRequestsOnceBucketIsEmpty: Ensures requests are blocked after the tokens are depleted. shouldRefillTokensGradually...: Uses Thread.sleep() to simulate time passing and confirms that tokens are refilled correctly. shouldHandleMultipleClientsIndependently: Checks that the rate limit for one client does not affect another. shouldRefillTokensUpToCapacity...: Verifies that the bucket doesn't overfill, even if a lot of time passes.

Let's walk through the first test as an example.

Test 1: Allowing requests within capacity
This test verifies the "happy path." If you have a bucket with a capacity of 10, the first 10 requests should succeed.

@Test
void shouldAllowRequestsWithinCapacity() {
    String key = "test-client:1";
    int capacity = 10;
    // Assume your rate limiter is configured with these parameters
    
    for (int i = 0; i < capacity; i++) {
        boolean allowed = rateLimiter.isAllowed(key);
        assertThat(allowed)
            .withFailMessage("Request %d should have been allowed", i + 1)
            .isTrue();
    }
}

Implement the other scenarios from the article in a similar fashion. Don't be concerned about using Thread.sleep() in these tests; while generally avoided, it's a pragmatic way to test time-dependent logic like this in an isolated environment.

B. Testing the RedisSlidingWindowRateLimiter

Now, let's apply the same principles to test your RedisSlidingWindowRateLimiter. The test structure will be nearly identical: a dedicated test class using Testcontainers and a @BeforeEach method to flush Redis.

Sliding Window Counter Rate Limiter (Redis & Java)

This companion article, 'Sliding Window Counter Rate Limiter (Redis & Java),' provides the test cases for our second algorithm.

Just as you did for the token bucket, read through and implement the test scenarios from this article for your RedisSlidingWindowRateLimiter. The key scenarios are: shouldAllowRequestsWithinLimit: The happy path. shouldDenyRequestsOnceLimitIsExceeded: The blocking behavior. shouldAllowRequestsAgainAfterSlidingWindowResets: Verifies that after the window duration passes, new requests are allowed. shouldHandleMultipleClientsIndependently: Confirms client isolation. shouldAllowRequestsAgainGraduallyInSlidingWindow: The most important test for this algorithm! It verifies the 'sliding' nature, where capacity becomes available as old requests expire from the window.

By completing these tests, you will have a comprehensive suite that validates the correctness of your distributed rate-limiting logic. This is a critical piece of evidence for the robustness of your portfolio project.



Conclusion

In this lesson, you've learned how to bridge the gap between application code and external dependencies in your testing strategy. By using Testcontainers, you've created reliable, automated tests that verify the real-world behavior of your Redis-based rate limiters.

Key Takeaways:

  • Testcontainers allows you to write high-fidelity tests for components interacting with databases, message queues, or any service that can run in a Docker container.
  • A good test suite for a rate limiter covers various scenarios: the happy path (within limits), the rejection path (exceeding limits), time-based expiration/refills, and isolation between different clients.
  • Using @DynamicPropertySource with Testcontainers in a Spring Boot application is the standard way to inject container-dependent properties like host and port into your test context.
  • Ensuring a clean state for each test (e.g., using FLUSHALL in @BeforeEach) is critical for creating independent and reliable tests.

Next Steps

Your rate-limiting algorithms are now implemented, integrated, and, most importantly, verified. You have a set of robust, interchangeable strategies. The next logical step is to build a system that can dynamically configure and select these strategies based on flexible rules.

In our next lesson, you will design a YAML schema for defining rate limit rules. This will be the first step in creating a truly dynamic and configurable rate limiter engine, allowing you to define different limits for different users, endpoints, or API keys without changing a single line of code.