Model-Based Testing with Testcontainers and Jqwik
December 8, 2025 · 846 words · 4 min
When testing complex systems, the more edge cases you can identify, the better your software perform
When testing complex systems, the more edge cases you can identify, the better your software performs in the real world. But how do you efficiently generate hundreds or thousands of meaningful tests that reveal hidden bugs? Enter model-based testing (MBT), a technique that automates test case generation by modeling your software’s expected behavior. In this demo, we’ll explore the model-based testing technique to perform regression testing on a simple REST API. We’ll use the test engine on JUnit 5 to run property and model-based tests. Additionally, we’ll use to spin up Docker containers with different versions of our application. Model-based testing is a method for testing stateful software by comparing the tested component with a model that represents the expected behavior of the system. Instead of manually writing test cases, we’ll use a testing tool that: In our case, the actions are simply the endpoints exposed by the application’s API. For the demo’s code examples, we’ll use a basic service with a CRUD REST API that allows us to: Once everything is configured and we finally run the test, we can expect to see a rapid sequence of hundreds of requests being sent to the two stateful services: Let’s assume we need to switch the database from Postgres to MySQL and want to ensure the service’s behavior remains consistent. To test this, we can run both versions of the application, send identical requests to each, and compare the responses. We can set up the environment using a Docker Compose that will run two versions of the app: At this point, we could start the application and databases manually for testing, but this would be tedious. Instead, let’s use Testcontainers’ to automate this with our Docker Compose file during the testing phase. In this example, we’ll use jqwik as our JUnit 5 test runner. First, let’s add the and and the dependencies to our : As a result, we can now instantiate a and pass our test file as argument: Now, let’s create a small test utility that will help us execute the HTTP requests against our services: Additionally, in the test class, we can declare another method that helps us create for the two services started by the : Jqwik is a property-based testing framework for Java that integrates with JUnit 5, automatically generating test cases to validate properties of code across diverse inputs. By using generators to create varied and random test inputs, jqwik enhances test coverage and uncovers edge cases. If you’re new to jqwik, you can explore their API in detail by reviewing the . While this tutorial won’t cover all the specifics of the API, it’s essential to know that jqwik allows us to define a set of actions we want to test. To begin with, we’ll use jqwik’s annotation — instead of the traditional — to define a test: Next, we’ll define the actions, which are the HTTP calls to our APIs and can also include assertions. For instance, the will try to fetch a specific employee from both services and compare the responses: Additionally, we’ll need to wrap these actions within objects. We can think of as objects implementing the factory design pattern that can generate a wide variety of instances of a type, based on a set of configured rules. For instance, the returned by can generate employee numbers by choosing a random department from the configured list and concatenating a number between 0 and 200: Similarly, returns an action based on a given employee number: After declaring all the other and , we’ll create an : Now, we can write our test and leverage jqwik to use the provided actions to test various sequences. Let’s create the tuple and use it to execute the sequence of actions against it: That’s it — we can finally run the test! The test will generate a sequence of thousands of requests trying to find inconsistencies between the model and the tested service: If we run the test and check the logs, we’ll quickly spot a failure. It appears that when searching for employees by department with the argument the model produces an internal server error, while the test version returns 200 OK: Upon investigation, we find that the issue arises from a native SQL query using Postgres-specific syntax to retrieve data. While this was a simple issue in our small application, model-based testing can help uncover unexpected behavior that may only surface after a specific sequence of repetitive steps pushes the system into a particular state. In this post, we provided hands-on examples of how model-based testing works in practice. From defining models to generating test cases, we’ve seen a powerful approach to improving test coverage and reducing manual effort. Now that you’ve seen the potential of model-based testing to enhance software quality, it’s time to dive deeper and tailor it to your own projects. to experiment further, customize the models, and integrate this methodology into your testing strategy. Start building more resilient software today!