Comprehensive Guide to Testing REST APIs: Best Practices, Tools, and Examples
As a software developer, you know that writing code is just the start. To make sure your app works as expected and lasts, you need to test it thoroughly. This is especially important for building REST APIs, where a bug can have big consequences.
In this post, we'll dive into testing REST APIs. We'll look at different types of tests you can do and tools to make the process smoother. Whether you're an experienced developer or just starting, this guide will give you the knowledge and examples you need to write robust and maintainable tests for your API.
Types of Tests for REST APIs
When it comes to testing REST APIs, there are several types of tests you can perform, each with its purpose and benefits. Let's take a closer look at them:
Various Types of Testing for REST APIs
When dealing with REST API testing, you have multiple test categories. Each serves its purpose and provides specific advantages. Let's explore these testing types:
Unit Testing
Unit testing is fundamental. You verify individual code units, like classes or methods. It helps catch bugs early in development. Unit tests ensure your code works properly.
Integration Testing
Integration testing goes beyond unit testing. It examines how different components interact, including your API's integration with external systems or services it utilizes.
Contract Testing
RehumanizeContract testing certifies that changes made to your API do not break any existing consumer contracts. This prevents issues when clients update their applications.
End-to-End (E2E) Testing
This type checks the whole user trip. From front-end clicks to backend code. It spots issues when parts work together. Not single parts.
User Acceptance Testing (UAT)
Real users perform this final test. They ensure the API meets business needs. It happens before code goes live. UAT is the last approval step.
Testing Tools: JUnit and Mockito
When it comes to testing Java-based REST APIs, two of the most popular tools are JUnit and Mockito. Let's take a closer look at how these tools can help you write effective tests.
JUnit
JUnit is a widely adopted unit testing framework for Java. It provides a set of annotations and assertions that make it easy to write and run tests. JUnit 5, the latest version of the framework, offers several improvements over previous versions, including better support for modern Java features and improved extensibility.
Annotations:
@Test
annotation specifies that the method is the test method.@ParameterizedTest
make it possible to run a test multiple times with different arguments. They are declared just like regular@Test
methods but use@ParameterizedTest
annotation instead.@BeforeEach
annotation specifies that the annotated method should be executed before each test method, analogous to JUnit 4’s@Before
.@BeforeAll
annotation is used to execute the annotated method, only after all tests have been executed. This is analogous to JUnit 4’s@AfterClass
.@DisplayName
annotation is used to declare custom display names that test runners and test reports will display.@AfterEach
annotation specifies that the method should be executed after each test method, analogous to JUnit 4’s@After.
@AfterAll
annotation is used to execute the annotated method, only after all tests have been executed. This is analogous to JUnit 4’s@AfterClass
Mockito
Mockito is a popular mocking framework that works seamlessly with JUnit. It allows you to replace external dependencies (such as databases or web services) with test doubles, making your unit tests faster, more reliable, and easier to maintain.
Annotations:
@Mock
annotation is used to create and inject mocked instances. We do not create real objects, rather ask Mockito to create a mock for the class.@InjectMocks
annotation marks a field on which injection should be performed**.** Mockito will try to inject mocks only either by constructor injection, setter injection, or property injection@Spy
annotation is used to create a real object and spy on that real object. A spy helps to call all the normal methods of the object while still tracking every interaction, just as we would with a mock.@Captor
annotation is used to create anArgumentCaptor
instance which is used to capture method argument values for further assertions.
Here's an example of how you can use JUnit5/Mockito to test simple REST API endpoints:
package com.sudip.rest.webservices.restfulwebservices.todo;
import com.sudip.rest.webservices.restfulwebservices.todo.repository.TodoRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
public class TodoController {
private final TodoRepository todoRepository;
public TodoController(TodoRepository todoRepository)
{
this.todoRepository = todoRepository;
}
// Retrieve all todos
@GetMapping("/users/{username}/todos")
public List<Todo> retrieveTodos(@PathVariable String username){
return todoRepository.findByUsername(username);
}
// Retrieve one todos
@GetMapping("/users/{username}/todos/{id}")
public Todo retrieveTodo(@PathVariable String username, @PathVariable int id){
return todoRepository.findById(id).get();
}
// Delete todos by id
@DeleteMapping("/users/{username}/todos/{id}")
public ResponseEntity<Void> deleteTodo(@PathVariable String username, @PathVariable int id){
todoRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}
The TodoController class is a Spring Boot REST controller that handles the retrieval and deletion of todo items. It has two main methods:
retrieveTodos(String username)
: This method is mapped to the/users/{username}/todos
endpoint and returns a list of all todo items for the specified username.retrieveTodo(String username, int id)
: This method is mapped to the/users/{username}/todos/{id}
endpoint and returns the todo item with the specified ID for the given username.deleteTodo(String username, int id)
: This method is mapped to the/users/{username}/todos/{id}
endpoint and deletes the todo item with the specified ID for the given username.
package com.sudip.rest.webservices.restfulwebservices;
import com.sudip.rest.webservices.restfulwebservices.todo.Todo;
import com.sudip.rest.webservices.restfulwebservices.todo.TodoController;
import com.sudip.rest.webservices.restfulwebservices.todo.repository.TodoRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for the TodoController class.
*/
@ExtendWith(MockitoExtension.class)
class TodoControllerTests {
@Mock
private TodoRepository todoRepository;
@InjectMocks
private TodoController todoController;
/**
* Test case to verify the successful retrieval of todos.
*/
@Test
void retrieveTodos_Success() {
// Arrange
String username = "testUser";
List<Todo> todos = Collections.singletonList(new Todo());
when(todoRepository.findByUsername(username)).thenReturn(todos);
// Act
List<Todo> result = todoController.retrieveTodos(username);
// Assert
assertEquals(todos.size(), result.size());
verify(todoRepository).findByUsername(username);
}
/**
* Test case to verify the successful retrieval of a single todo.
*/
@Test
void retrieveTodo_Success() {
// Arrange
int id = 1;
Todo todo = new Todo();
when(todoRepository.findById(id)).thenReturn(Optional.of(todo));
// Act
Todo result = todoController.retrieveTodo("testUser", id);
// Assert
assertEquals(todo, result);
verify(todoRepository).findById(id);
}
/**
* Test case to verify the successful deletion of a todo.
*/
@Test
void deleteTodo_Success() {
// Arrange
int id = 1;
// Act
ResponseEntity<Void> responseEntity = todoController.deleteTodo("testUser", id);
// Assert
assertEquals(HttpStatus.NO_CONTENT, responseEntity.getStatusCode());
verify(todoRepository).deleteById(id);
}
/**
* Test case to verify the retrieval of todos when the list is empty.
*/
@Test
void retrieveTodos_EmptyList() {
// Arrange
String username = "testUser";
when(todoRepository.findByUsername(username)).thenReturn(Collections.emptyList());
// Act
List<Todo> result = todoController.retrieveTodos(username);
// Assert
assertEquals(0, result.size());
verify(todoRepository).findByUsername(username);
}
}
Here's a breakdown of the code:
Imports: The test class imports the necessary classes and annotations, including the
Todo
,TodoController
, andTodoRepository
classes, as well as the JUnit5 and Mockito libraries for testing.TodoControllerTest Class: This class is responsible for testing the
TodoController
class.Field Declarations: Here, we declare fields for the
TodoRepository
andTodoController
. The@Mock
annotation creates a mock instance ofTodoRepository
, and the@InjectMocks
annotation injects it intotodoController
.Setup Method: This
@BeforeEach
the method is called before each test and initializes the Mockito mocks using theMockitoAnnotations.openMocks(this)
method.Test Methods: These are the actual test methods. Each method is annotated with
@Test
to indicate that it is a test case.retrieveTodos_Success Test: This test verifies the successful retrieval of todos. It sets up a mock
TodoRepository
to return a list ofTodo
objects when thefindByUsername
the method is called. It then calls theretrieveTodos
method of theTodoController
and asserts that the returned list has the same size as the expected list. Finally, it verifies that thefindByUsername
method of theTodoRepository
was called.retrieveTodo_Success Test: This test verifies the successful retrieval of a single todo. It sets up a mock
TodoRepository
to return aOptional
containing anTodo
object when thefindById
the method is called. It then calls theretrieveTodo
methodTodoController
and asserts that the returnedTodo
the object is the same as the expected one. Finally, it verifies that thefindById
method of theTodoRepository
was called.deleteTodo_Success Test: This test verifies the successful deletion of a todo. It calls the
deleteTodo
method of theTodoController
and asserts that the returnedResponseEntity
has a status code ofHttpStatus.NO
_CONTENT
. It also verifies that thedeleteById
method of theTodoRepository
was called.retrieveTodos_EmptyList Test: This test verifies that when the
retrieveTodos
method ofTodoController
is called with a specific username and thefindByUsername
method ofTodoRepository
returns an empty list, the returned list fromretrieveTodos
is also empty. It ensures that the controller behaves correctly when there are no todos for the given username.
Best Practices for Effective Test Writing
Writing effective tests is an art, and it takes practice to master. Here are some best practices to keep in mind:
Names that Speak for Themselves
Let's face it, clever test names are fun, but clarity is king. Your test names should be like mini-sentences, telling everyone exactly what functionality is being tested and what the expected outcome is. Think of it as a quick cheat sheet for you or a future teammate to understand the test's purpose at a glance.
Break Everything That Could Possibly Break
When writing tests, think like a gremlin whose sole purpose is to break your code. Don't just test the happy path – go after those edge cases and weird inputs that might send your code into a tailspin. By testing these extremes, you're proactively identifying potential issues before they wreak havoc in production.
Focus on the Big Wins
There's no need to write a test for every single line of code, especially getters and setters. These are usually pretty straightforward. Instead, prioritize testing the core logic of your application, the parts where bugs are more likely to lurk.
Invalid Input? No Problem!
Imagine a user throwing unexpected data at your code. Can it handle it gracefully? Make sure your tests cover these scenarios where invalid parameters are passed to your functions. This ensures your application doesn't crash and burn but instead provides clear error messages to users.
Tests are Living Documentation
Ever spend hours debugging only to realize a well-written test could have saved the day? When you find a bug, resist the urge to jump straight into debug mode. Instead, write a targeted test that exposes the issue. This not only helps you squash the bug but also strengthens your test suite and acts as living documentation for your code's behavior.
Keeping away from
@ignore
That @ignore
annotation might be tempting for flaky tests, but fight the urge! Skipped tests provide no value. If a test is truly broken, fix it or remove it entirely. A clean test suite is a happy test suite (and a happy programmer).
Logs, Not Screams
When you need to peek into your test's inner workings, use logs instead of print statements. Print statements can clutter your output, making it hard to decipher what's going on. Logs provide a cleaner way to track test execution and ensure your tests are focused on the core functionality, not debugging messages.
Conclusion
By following these practices, you'll be well on your way to writing effective tests that keep your code healthy and your sanity intact. Remember, good tests are an investment in the future – they'll save you time, and frustration, and help you ship high-quality code.
Subscribe to my newsletter
Read articles from Sudip Parajuli directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Sudip Parajuli
Sudip Parajuli
I am an aspiring Developer from Nepal.