TDD in Java
- Java 11;
- Spring Boot;
- Spring Security;
- Spring Test;
- Spring Data;
- Spring Web;
- MySQL;
- H2;
- JUnit 5;
- Mockito;
- Hamcrest;
- Lombok.
Test Driven Development is a process that consists of turning the requirements of the software application into specific test cases (acceptance criteria) and then implement the source code.
This process uses the red/green/refactor pattern and consists of the following steps:
- Create Test
- Run Tests (should fail - Red)
- Write Code
- Run Tests (should pass - Green)
- Refactor
Let's start from Controller. First we create BookControllerTest and we are going to create an end point for: /api/books/{name}.
@ExtendWith(SpringExtension.class)
@AutoConfigureMockMvc
@SpringBootTest
public class BookControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void shouldReturnBookWithAuthor() throws Exception {
mockMvc.perform(get("/api/books/Idiot"))
.andExpect(status().isOk())
.andDo(print());
}
}
Code will give us an error saying that BookController class is not available. Here we'll go to main folder and will create just BookController.
public class BookController {
}
Now the compilation error is resolved. When we run BookControllerTest class now, we end-up with the failed message as below:
The reason for below error is that, because there is no rest endpoint with url /api/books/Idiot in BookController class. Let's create the endpoint in BookController class.
@RestController
@RequestMapping("/api/books")
public class BookController {
@GetMapping("/{name}")
public ResponseEntity<Book> getBook(@PathVariable String name) throws Exception {
return new ResponseEntity<>( HttpStatus.OK);
}
}
And now we create Book model.
@Entity
@Table(name = "book")
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "author_id")
private Author author;
public Book(String name) {
this.name = name;
}
}
Now in BookController class lets add below codes:
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping("/{name}")
public ResponseEntity<Book> getBook(@PathVariable String name) throws Exception {
Book book = new Book();
return new ResponseEntity<>(book,HttpStatus.OK);
}
}
Now lets navigate to BookControllerTest class and add few more point to existing table as below:
@Test
public void shouldReturnBookWithAuthor() throws Exception{
mockMvc.perform(MockMvcRequestBuilders.get("/books/Idiot"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isMap())
.andExpect(jsonPath("name").value("Idiot"))
.andDo(print());
}
From above code, what are we expecting from test is that, then the response should contain Book object with 'Idiot" name.
The reason for this that we are passing book object with null values in controller. Create Book service class and inject in controller.
@Service
public class BookService {
public Book getBookByName(String name) {
return null;
}
}
Now in BookControllerTest, we are going to mock BookService class. And we are mocking BookService object with @MockBean annotation.
@ExtendWith(SpringExtension.class)
@AutoConfigureMockMvc
@SpringBootTest
public class BookControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookService bookService;
@Test
public void shouldReturnBookWithAuthor() throws Exception {
given(bookService.getBookByName("Idiot")).willReturn(
Book.builder().name("Idiot")
.author(Author.builder().firstName("Fyodor").lastName("Dostoyevsky").build())
.build());
mockMvc.perform(get("/api/books/Idiot"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isMap())
.andExpect(jsonPath("name").value("Idiot"))
.andExpect(jsonPath("author.firstName").value("Fyodor"))
.andExpect(jsonPath("author.lastName").value("Dostoyevsky"))
.andDo(print());
}
}
With above code we are defining the behaviour in such a way that, if we pass any string as name it should return new Book.
Lets assume if there isn't book with such name, what would happen. For this we need to create NotFoundException class which will be throwed when there isn't book with such name.
@ResponseStatus(code = HttpStatus.NOT_FOUND)
public class NotFoundException extends RuntimeException{
public NotFoundException(String message) {
super(message);
}
}
Again we are going to have another test to validate this scenario. If you run this test method it will work like work.
@Test
public void shouldReturn404WhenBookNotFound() throws Exception {
given(bookService.getBookByName(Mockito.anyString())).willThrow(new NotFoundException("Book didn't found"));
mockMvc.perform(get("/api/books/Idiot"))
.andExpect(status().isNotFound());
}
Now create BookServiceTest class,
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class BookServiceTest {
@InjectMocks
private BookService bookService;
@Mock
private BookRepository bookRepository;
@Test
public void shouldReturnBook(){
given(bookRepository.findByName("Idiot")).willReturn(Optional.of(new Book("Idiot")));
Book book = bookService.getBookByName("Idiot");
assertNotNull(book);
assertEquals("Idiot", book.getName());
}
}
We need to ensure that the BookRepository's method findByName should give us proper data fetched from database and use H2Database. We create init.sql file:
With above line we are defining the behaviour. Before every test will call select method in database for out test.
@Sql(value = {"/book-init.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@ExtendWith(SpringExtension.class)
@SpringBootTest
@TestPropertySource("/application-test.properties")
@Sql(value = {"/book-init.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
public class BookRepositoryTest {
@Autowired
private BookRepository bookRepository;
@Test
public void shouldReturnBookSearchedByName(){
Optional<Book> book = bookRepository.findByName("Idiot");
assertTrue(book.isPresent());
}
}
And we see select from db and test is passed.