@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public ResponseEntity<Order> getOrder(@PathVariable long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request.customer(), request.amount());
}
}
public record CreateOrderRequest(
@NotBlank(message = "customer is required")
String customer,
@Positive(message = "amount must be positive")
BigDecimal amount
) {
}
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private OrderService orderService;
@Test
void returnsOrderWhenFound() throws Exception {
Order order = new Order(7L, "acme", new BigDecimal("42.50"));
given(orderService.findById(7L)).willReturn(Optional.of(order));
mockMvc.perform(get("/api/orders/{id}", 7))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(7))
.andExpect(jsonPath("$.customer").value("acme"))
.andExpect(jsonPath("$.amount").value(42.50));
}
@Test
void returnsNotFoundWhenMissing() throws Exception {
given(orderService.findById(99L)).willReturn(Optional.empty());
mockMvc.perform(get("/api/orders/{id}", 99))
.andExpect(status().isNotFound());
}
@Test
void createsOrderAndReturns201() throws Exception {
CreateOrderRequest request = new CreateOrderRequest("acme", new BigDecimal("10.00"));
Order saved = new Order(1L, "acme", new BigDecimal("10.00"));
given(orderService.create(eq("acme"), any(BigDecimal.class))).willReturn(saved);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1));
}
@Test
void rejectsInvalidPayload() throws Exception {
CreateOrderRequest invalid = new CreateOrderRequest("", new BigDecimal("-5"));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalid)))
.andExpect(status().isBadRequest());
verifyNoInteractions(orderService);
}
}
This snippet demonstrates the controller-slice testing pattern in Spring Boot, where only the web layer is loaded and everything below it is mocked. In OrderController, the controller depends on an OrderService and exposes two endpoints: a GET /api/orders/{id} that returns a single order and a POST /api/orders that validates a request body and creates one. The controller stays thin — it delegates business logic to the service and only concerns itself with HTTP status codes, path variables, and JSON binding.
The request payload in CreateOrderRequest carries Jakarta Bean Validation annotations (@NotBlank, @Positive), and the controller opts into them with @Valid. This matters for the test because @WebMvcTest boots the full MVC machinery — message converters, argument resolvers, and the validation pipeline — so validation failures are exercised for real rather than stubbed.
The heart of the example is OrderControllerTest, annotated with @WebMvcTest(OrderController.class). That annotation loads a narrow context containing just that controller plus MVC infrastructure; it deliberately excludes services, repositories, and @Component beans, which keeps the test fast and focused. Because the real OrderService is not in the context, it is supplied as a @MockBean, letting Mockito control its behavior per test.
The injected MockMvc sends simulated requests without starting a servlet container. In returnsOrderWhenFound, given(...).willReturn(...) stubs the service, and the fluent andExpect chain asserts the status and drills into the JSON body with jsonPath. In returnsNotFoundWhenMissing, the service returns an empty Optional so the controller's mapping to 404 is verified. The rejectsInvalidPayload test posts a body that violates the constraints and expects 400, confirming the validation wiring end to end.
The trade-off is scope: this approach proves the HTTP contract, serialization, and validation, but not the service internals or the database — those belong in separate unit or @SpringBootTest integration tests. Reaching for @WebMvcTest is ideal when the goal is fast, targeted feedback on a single controller's request and response behavior without the cost of a full application context.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
use my_crate::add;
#[test]
fn test_public_api() {
assert_eq!(add(3, 4), 7);
}
Integration tests in tests/ directory
Share this code
Here's the card — post it anywhere.