DrillLab

六个 Spring Boot REST 端点Six Spring Boot REST endpoints

Java / Spring困难 · Hard约 75 分钟~75 min本机跑Run it locally
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

给你 OrderService 的方法签名和五个测试。 自己搭一个 Spring Boot 项目,写出六个端点。不要打开源项目的 OrderController.java。

You are given the method signatures of OrderService and five tests. Set up a Spring Boot project yourself and write six endpoints. Do not open OrderController.java from the source project.

验收标准Acceptance criteria
  • Spring Boot 3.3 + Java 17,依赖 web / validation / actuator / testSpring Boot 3.3 + Java 17, with the web / validation / actuator / test dependencies
  • 一个 @RestController,构造器注入 OrderServiceOne @RestController, with OrderService injected through the constructor
  • GET /api/orders:?userId= 传了就按用户过滤,没传返回全部;200GET /api/orders: filter by user when ?userId= is given, return everything when it is not; 200
  • GET /api/orders/{id}:200;找不到时由全局异常处理器给出 404(控制器不要 catch)GET /api/orders/{id}: 200; when nothing is found, the global exception handler answers 404 (do not catch it in the controller)
  • GET /api/orders/user/{userId}:200GET /api/orders/user/{userId}: 200
  • POST /api/orders:@Valid 校验请求体;成功返回 201 CreatedPOST /api/orders: validate the request body with @Valid; on success return 201 Created
  • PATCH /api/orders/{id}/status:body 是 {"status":"..."};转成 OrderStatus;缺失或非法值返回 400;成功 200PATCH /api/orders/{id}/status: the body is {"status":"..."}; convert it to OrderStatus; a missing or invalid value returns 400; on success 200
  • DELETE /api/orders/{id}:204 No ContentDELETE /api/orders/{id}: 204 No Content
  • 六个端点都用 SLF4J 打日志,并带上 MDC 里的 correlationIdAll six endpoints log through SLF4J and carry the correlationId from MDC
  • 自己写一个 CorrelationIdFilter:读 X-Correlation-ID 头,没有就生成 UUID,放进 MDC,finally 里清理Write your own CorrelationIdFilter: read the X-Correlation-ID header, generate a UUID when it is absent, put it in MDC, and clear it in finally
  • 自己写 GlobalExceptionHandler:EntityNotFoundException → 404,MethodArgumentNotValidException → 400Write your own GlobalExceptionHandler: EntityNotFoundException → 404, MethodArgumentNotValidException → 400

预计 75 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 75 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区Workspace

这道题在本机跑Run this one on your own machine

这道题要 JVM 和 Maven。浏览器里没有 JVM,装不出来也不该装 —— 所以这里只给命令和期望输出。This one needs a JVM and Maven. There is no JVM in a browser and there should not be, so you get the commands and the expected output instead.

mvn test
Tests run: 5, Failures: 0
mvn spring-boot:run
BUILD SUCCESS,服务起在 8080

跑完自己对一遍期望输出,然后在下面打勾。这里不给假编辑器 —— 装个能跑的样子只会让你以为练过了。Compare the output yourself, then tick it off below. No fake editor here.

自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《六个端点:状态码就是这道题的全部》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “六个端点:状态码就是这道题的全部” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《六个端点:状态码就是这道题的全部》(8 段 · 约 18 分钟)Expand “六个端点:状态码就是这道题的全部” (8 sections · ~18 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 六个端点:状态码就是这道题的全部

§01

题面与 starterThe question and the starter code

README 里 Task 2 的原文:

六个端点,六个 // TODO, 六个 return null业务逻辑一行都不用你写—— 全在 OrderService 里。

Task 2 in the README, word for word:

Six endpoints, six // TODO markers, six return null statements. You write no business logic at all — it is all sitting in OrderService.

TextREADME.md(Task 2 原文)README.md (the original Task 2 text)源项目From source
1## Task 2: Spring Boot REST Controller
2
3Implement REST endpoints in
4`java-service/src/main/java/com/techflow/orders/controller/OrderController.java`:
5
6- `GET /api/orders`
7- `GET /api/orders/{id}`
8- `GET /api/orders/user/{userId}`
9- `POST /api/orders`
10- `PATCH /api/orders/{id}/status`
11- `DELETE /api/orders/{id}`
12
13Use the provided `OrderService` for business logic.
Source: graphql-federation-practice/README.md
JavaOrderController.java(starter)OrderController.java (starter)源项目From source
1@RestController
2public class OrderController {
3 private final OrderService orderService;
4
5 public OrderController(OrderService orderService) {
6 this.orderService = orderService;
7 }
8
9 @GetMapping("/")
10 public ResponseEntity<Map<String, String>> getRoot() {
11 return ResponseEntity.ok(Map.of(
12 "message", "Welcome to TechFlow Order Service API",
13 "status", "running"
14 ));
15 }
16
17 @GetMapping("/api/orders")
18 public ResponseEntity<List<Order>> getAllOrders(
19 @RequestParam(required = false) String userId) {
20 // TODO: Implement REST endpoint with structured logging and request validation
21 return null;
22 }
23
24 @GetMapping("/api/orders/{id}")
25 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
26 // TODO: Implement GET endpoint for order by ID
27 return null;
28 }
29
30 @GetMapping("/api/orders/user/{userId}")
31 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
32 // TODO: Implement GET endpoint for orders by user ID
33 return null;
34 }
35
36 @PostMapping("/api/orders")
37 public ResponseEntity<Order> createOrder(
38 @Valid @RequestBody CreateOrderRequest request) {
39 // TODO: Implement POST endpoint with Bean Validation and proper HTTP status
40 return null;
41 }
42
43 @PatchMapping("/api/orders/{id}/status")
44 public ResponseEntity<Order> updateOrderStatus(
45 @PathVariable Long id,
46 @RequestBody Map<String, String> statusUpdate) {
47 // TODO: Implement PATCH endpoint for order status updates
48 return null;
49 }
50
51 @DeleteMapping("/api/orders/{id}")
52 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
53 // TODO: Implement DELETE endpoint
54 return null;
55 }
56}
Source: graphql-federation-practice/java-service/src/main/java/com/techflow/orders/controller/OrderController.java
§02

实测:六个端点全返回 null,五个测试过了三个Measured: all six endpoints return null, and three of five tests pass

这是本项目最值得记住的一个事实。This is the single fact from this project most worth remembering.

审计时我在临时目录里跑了 baseline(原封不动的 starter):

为什么 return null 能过?因为 Spring 里,控制器方法返回 null 时, 框架认为「你已经自己处理完响应了」, 于是返回一个 200 OK + 空 body

而那三个通过的测试断言的是 status().isOk() —— 也就是 200。正好符合。

只有两条测试断言了非 200 的状态码 (isCreated() = 201、isNoContent() = 204), 所以只有它们抓住了错。

结论和 Node 那边一样,但更极端:三个端点完全没实现,测试却是绿的。如果你只看测试,会以为自己做完了 60%, 实际上是 0%。

During the audit I ran the baseline in a scratch copy — the starter, untouched:

Why does returning null pass? Because when a Spring controller method returns null, the framework assumes you already dealt with the response yourself, so it sends back a 200 OK with an empty body.

And the three tests that passed assert status().isOk() — that is, 200. Exact match.

Only two tests assert a status other than 200 (isCreated() = 201, isNoContent() = 204), so those two are the only ones that caught anything.

Same lesson as on the Node side, only worse: three endpoints are not implemented at all and the suite is green. Judging by tests alone you would think you were 60% done. You are at 0%.

Terminal本机实测(scratchpad 副本,未改动源项目)Measured locally (on a scratch copy; the source project was not touched)已跑通Verified
1$ mvn test # baseline,六个端点全是 return null
2
3[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
4[ERROR] OrderControllerTest.shouldCreateOrder:58 Status expected:<201> but was:<200>
5[ERROR] OrderControllerTest.shouldDeleteOrder:74 Status expected:<204> but was:<200>
6
7# 通过的三个:
8# ✓ shouldGetAllOrders 断言 isOk() → return null 也是 200
9# ✓ shouldGetOrderById 断言 isOk() → 同上
10# ✓ shouldUpdateOrderStatus 断言 isOk() → 同上
1$ mvn test # baseline: all six endpoints just return null
2
3[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
4[ERROR] OrderControllerTest.shouldCreateOrder:58 Status expected:<201> but was:<200>
5[ERROR] OrderControllerTest.shouldDeleteOrder:74 Status expected:<204> but was:<200>
6
7# The three that pass:
8# ✓ shouldGetAllOrders asserts isOk() → return null is a 200 too
9# ✓ shouldGetOrderById asserts isOk() → same
10# ✓ shouldUpdateOrderStatus asserts isOk() → same
§03

五个状态码,各自什么时候用Five status codes, and when each one is used

状态码语义本题里哪个端点怎么写
200 OK成功,有内容返回三个 GET、PATCHResponseEntity.ok(body)
201 Created创建成功POSTResponseEntity.status(HttpStatus.CREATED).body(created)
204 No Content成功,没有内容返回DELETEResponseEntity.noContent().build()
400 Bad Request请求本身不合法POST(Bean Validation 自动)、 PATCH(status 非法,要你写throw new ResponseStatusException(HttpStatus.BAD_REQUEST, msg)
404 Not Found目标不存在GET by id、PATCH、DELETE (全局处理器自动)不写 —— 让 service 的异常冒出去

201 和 204 是这道题的分水岭。它们也是最容易忘的 —— 因为ResponseEntity.ok() 用惯了, 手会自动打出来。

记一个判据:「有东西返回吗?」没有 → 204。 「是新造了一个资源吗?」是 → 201。

StatusMeaningWhich endpoint hereHow to write it
200 OKSuccess, with contentthe three GETs and PATCHResponseEntity.ok(body)
201 CreatedCreated somethingPOSTResponseEntity.status(HttpStatus.CREATED).body(created)
204 No ContentSuccess, with no contentDELETEResponseEntity.noContent().build()
400 Bad RequestThe request itself is invalidPOST (Bean Validation does it), PATCH (a bad status, you write this one)throw new ResponseStatusException(HttpStatus.BAD_REQUEST, msg)
404 Not FoundThe target does not existGET by id, PATCH, DELETE (the global handler does it)Write nothing — let the service exception bubble out

201 and 204 are the watershed of this paper. They are also the easiest to forget, because your hands type ResponseEntity.ok() out of habit.

One test to remember: “Is there anything to send back?” No → 204. “Did I just create a resource?” Yes → 201.

§04

GET /api/orders 的可选过滤The optional filter on GET /api/orders

第一个端点的签名里有一个@RequestParam(required = false) String userId这个参数是提示: 它要求你实现「可选过滤」。

?userId=123 传了 → 只返回那个用户的订单。 没传(userIdnull)→ 返回全部。

注意要同时判断 null 和空白。?userId= 这种写法会让 userId 是空字符串 而不是 null,用 isBlank() 一起挡掉。

TODO 原文写的是with structured logging and request validation—— 「request validation」指的就是这个判断。

The first endpoint’s signature contains a @RequestParam(required = false) String userId. That parameter is the hint: it is asking you to implement optional filtering.

?userId=123 was sent → return only that user’s orders. Not sent (userId is null) → return everything.

Check for blank as well as null. A request like ?userId= gives you an empty string, not null, so block both with isBlank().

The TODO reads with structured logging and request validation — that “request validation” is exactly this check.

Java第一个端点(参考答案)The first endpoint (reference answer)已跑通Verified
1@GetMapping("/api/orders")
2public ResponseEntity<List<Order>> getAllOrders(
3 @RequestParam(required = false) String userId) {
4 logger.info("GET /api/orders userId={}, correlationId={}", userId, correlationId());
5
6 List<Order> orders = (userId == null || userId.isBlank())
7 ? orderService.getAllOrders()
8 : orderService.getOrdersByUserId(userId);
9
10 return ResponseEntity.ok(orders);
11}
§05

PATCH 端点:字符串转 enum 是唯一需要动脑的地方The PATCH endpoint: turning a string into an enum is the only part that needs thought

这个端点收 Map 而不是 DTO,所以没有 Bean Validation 保护。This endpoint takes a Map instead of a DTO, so Bean Validation does not protect it.

请求体是 {"status":"SHIPPED"}, 但 orderService.updateOrderStatus需要的是 OrderStatus 枚举。 所以必须转换。

OrderStatus.valueOf("SHIPPED") 能转, 但有两个坑:

  • valueOf 大小写敏感。valueOf("shipped") 会抛IllegalArgumentException。 所以要 toUpperCase()
  • 非法值会抛异常。valueOf("FLYING")IllegalArgumentException, 而全局处理器没有处理它—— 所以会变成 500。 但语义上这是客户端的错,应该是 400

这里是本题唯一该写 try/catch 的地方—— 因为要把一种异常转成不同的状态码。 用 ResponseStatusException 最省事: 它是 Spring 提供的「带状态码的异常」, 不需要额外的 handler。

还要挡住 status 缺失的情况。{} 这种 body 会让statusUpdate.get("status") 返回 null,null.trim() 直接 NPE → 500。 同样应该是 400。

这两条测试都没查。测试只发了合法的 SHIPPED。 但它们是明显的正确性问题 —— 人工 review 会看。

The request body is {"status":"SHIPPED"}, but orderService.updateOrderStatus wants an OrderStatus enum. So you have to convert.

OrderStatus.valueOf("SHIPPED") does the conversion, but it has two traps:

  • valueOf is case sensitive. valueOf("shipped") throws IllegalArgumentException. So call toUpperCase() first.
  • An invalid value throws. valueOf("FLYING") throws IllegalArgumentException, and the global handler does not handle that one — so it comes out as a 500. But this is the client’s mistake, so it should be a 400.

This is the one place in this paper where try/catch belongs — because you are turning one exception into a different status code. ResponseStatusException is the cheapest way: it is Spring’s built-in “exception carrying a status code”, and it needs no extra handler.

Block the missing-status case too. A body of {} makes statusUpdate.get("status") return null, and null.trim() is an NPE → 500. That should be a 400 as well.

Neither of these is tested. The tests only send a valid SHIPPED. But they are plain correctness bugs — a human reviewer will look.

JavaPATCH 端点(参考答案)The PATCH endpoint (reference answer)已跑通Verified
1@PatchMapping("/api/orders/{id}/status")
2public ResponseEntity<Order> updateOrderStatus(
3 @PathVariable Long id,
4 @RequestBody Map<String, String> statusUpdate) {
5 String raw = statusUpdate.get("status");
6 logger.info("PATCH /api/orders/{}/status status={}, correlationId={}",
7 id, raw, correlationId());
8
9 if (raw == null || raw.isBlank()) {
10 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status is required");
11 }
12
13 final OrderStatus status;
14 try {
15 status = OrderStatus.valueOf(raw.trim().toUpperCase());
16 } catch (IllegalArgumentException ex) {
17 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown status: " + raw);
18 }
19
20 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
21}
§06

六个端点的完整实现The complete implementation of all six endpoints

审计实测:这样写之后 5 个测试全过,BUILD SUCCESS。Measured during the audit: with this code all 5 tests pass and the build reports BUILD SUCCESS.

注意最下面那个私有方法 correlationId() —— 把 MDC.get("correlationId")包一层,六个端点都能用,也让日志语句短一些。这种小重构在 review 里是加分项。

需要新增的 import:OrderStatusLogger / LoggerFactoryMDCHttpStatusResponseStatusException。 starter 里没有它们。

Notice the little private correlationId() method at the bottom. It wraps MDC.get("correlationId") so all six endpoints can use it and the log statements stay short. A small refactor like that scores points in review.

Imports you have to add: OrderStatus, Logger / LoggerFactory, MDC, HttpStatus, ResponseStatusException. None of them are in the starter.

JavaOrderController.java(完整参考答案,实测 5/5 通过)OrderController.java (the full reference answer, measured 5/5 passing)已跑通Verified
1@RestController
2public class OrderController {
3 private static final Logger logger = LoggerFactory.getLogger(OrderController.class);
4
5 private final OrderService orderService;
6
7 public OrderController(OrderService orderService) {
8 this.orderService = orderService;
9 }
10
11 @GetMapping("/")
12 public ResponseEntity<Map<String, String>> getRoot() {
13 return ResponseEntity.ok(Map.of(
14 "message", "Welcome to TechFlow Order Service API",
15 "status", "running"
16 ));
17 }
18
19 @GetMapping("/api/orders")
20 public ResponseEntity<List<Order>> getAllOrders(
21 @RequestParam(required = false) String userId) {
22 logger.info("GET /api/orders userId={}, correlationId={}", userId, correlationId());
23
24 // 可选过滤:?userId=123 收窄集合,而不是另开一个路由
25 List<Order> orders = (userId == null || userId.isBlank())
26 ? orderService.getAllOrders()
27 : orderService.getOrdersByUserId(userId);
28
29 return ResponseEntity.ok(orders);
30 }
31
32 @GetMapping("/api/orders/{id}")
33 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
34 logger.info("GET /api/orders/{} correlationId={}", id, correlationId());
35
36 // 找不到时 service 抛 EntityNotFoundException,
37 // 由 GlobalExceptionHandler 转成 404
38 return ResponseEntity.ok(orderService.getOrderById(id));
39 }
40
41 @GetMapping("/api/orders/user/{userId}")
42 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
43 logger.info("GET /api/orders/user/{} correlationId={}", userId, correlationId());
44 return ResponseEntity.ok(orderService.getOrdersByUserId(userId));
45 }
46
47 @PostMapping("/api/orders")
48 public ResponseEntity<Order> createOrder(
49 @Valid @RequestBody CreateOrderRequest request) {
50 logger.info("POST /api/orders userId={}, correlationId={}",
51 request.getUserId(), correlationId());
52
53 Order created = orderService.createOrder(request);
54
55 // 创建成功返回 201,不是 200
56 return ResponseEntity.status(HttpStatus.CREATED).body(created);
57 }
58
59 @PatchMapping("/api/orders/{id}/status")
60 public ResponseEntity<Order> updateOrderStatus(
61 @PathVariable Long id,
62 @RequestBody Map<String, String> statusUpdate) {
63 String raw = statusUpdate.get("status");
64 logger.info("PATCH /api/orders/{}/status status={}, correlationId={}",
65 id, raw, correlationId());
66
67 if (raw == null || raw.isBlank()) {
68 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status is required");
69 }
70
71 final OrderStatus status;
72 try {
73 status = OrderStatus.valueOf(raw.trim().toUpperCase());
74 } catch (IllegalArgumentException ex) {
75 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown status: " + raw);
76 }
77
78 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
79 }
80
81 @DeleteMapping("/api/orders/{id}")
82 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
83 logger.info("DELETE /api/orders/{} correlationId={}", id, correlationId());
84
85 orderService.deleteOrder(id);
86
87 // 没有内容可返回 -> 204
88 return ResponseEntity.noContent().build();
89 }
90
91 /** CorrelationIdFilter 放在 MDC 里的 correlation id,用于串联日志 */
92 private String correlationId() {
93 return MDC.get("correlationId");
94 }
95}
1@RestController
2public class OrderController {
3 private static final Logger logger = LoggerFactory.getLogger(OrderController.class);
4
5 private final OrderService orderService;
6
7 public OrderController(OrderService orderService) {
8 this.orderService = orderService;
9 }
10
11 @GetMapping("/")
12 public ResponseEntity<Map<String, String>> getRoot() {
13 return ResponseEntity.ok(Map.of(
14 "message", "Welcome to TechFlow Order Service API",
15 "status", "running"
16 ));
17 }
18
19 @GetMapping("/api/orders")
20 public ResponseEntity<List<Order>> getAllOrders(
21 @RequestParam(required = false) String userId) {
22 logger.info("GET /api/orders userId={}, correlationId={}", userId, correlationId());
23
24 // Optional filter: ?userId=123 narrows the set instead of adding a route
25 List<Order> orders = (userId == null || userId.isBlank())
26 ? orderService.getAllOrders()
27 : orderService.getOrdersByUserId(userId);
28
29 return ResponseEntity.ok(orders);
30 }
31
32 @GetMapping("/api/orders/{id}")
33 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
34 logger.info("GET /api/orders/{} correlationId={}", id, correlationId());
35
36 // When it is not found the service throws EntityNotFoundException,
37 // and GlobalExceptionHandler turns that into a 404
38 return ResponseEntity.ok(orderService.getOrderById(id));
39 }
40
41 @GetMapping("/api/orders/user/{userId}")
42 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
43 logger.info("GET /api/orders/user/{} correlationId={}", userId, correlationId());
44 return ResponseEntity.ok(orderService.getOrdersByUserId(userId));
45 }
46
47 @PostMapping("/api/orders")
48 public ResponseEntity<Order> createOrder(
49 @Valid @RequestBody CreateOrderRequest request) {
50 logger.info("POST /api/orders userId={}, correlationId={}",
51 request.getUserId(), correlationId());
52
53 Order created = orderService.createOrder(request);
54
55 // A successful create returns 201, not 200
56 return ResponseEntity.status(HttpStatus.CREATED).body(created);
57 }
58
59 @PatchMapping("/api/orders/{id}/status")
60 public ResponseEntity<Order> updateOrderStatus(
61 @PathVariable Long id,
62 @RequestBody Map<String, String> statusUpdate) {
63 String raw = statusUpdate.get("status");
64 logger.info("PATCH /api/orders/{}/status status={}, correlationId={}",
65 id, raw, correlationId());
66
67 if (raw == null || raw.isBlank()) {
68 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status is required");
69 }
70
71 final OrderStatus status;
72 try {
73 status = OrderStatus.valueOf(raw.trim().toUpperCase());
74 } catch (IllegalArgumentException ex) {
75 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown status: " + raw);
76 }
77
78 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
79 }
80
81 @DeleteMapping("/api/orders/{id}")
82 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
83 logger.info("DELETE /api/orders/{} correlationId={}", id, correlationId());
84
85 orderService.deleteOrder(id);
86
87 // Nothing to return -> 204
88 return ResponseEntity.noContent().build();
89 }
90
91 /** The correlation id CorrelationIdFilter put in the MDC, to tie logs together */
92 private String correlationId() {
93 return MDC.get("correlationId");
94 }
95}
Terminal审计时的真实输出(参考解法)The real output from the audit (with the reference answer)已跑通Verified
1$ mvn test
2
3[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
4[INFO] BUILD SUCCESS
5[INFO] Total time: 18.694 s
6
7# 日志里能看到 correlationId 正常注入:
8INFO c.t.orders.controller.OrderController : DELETE /api/orders/1 correlationId=0e157516-...
9INFO c.t.orders.controller.OrderController : PATCH /api/orders/1/status status=SHIPPED, correlationId=7b441d8d-...
10INFO c.t.orders.controller.OrderController : POST /api/orders userId=123, correlationId=3bf0f9c5-...
1$ mvn test
2
3[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
4[INFO] BUILD SUCCESS
5[INFO] Total time: 18.694 s
6
7# The logs show correlationId being injected correctly:
8INFO c.t.orders.controller.OrderController : DELETE /api/orders/1 correlationId=0e157516-...
9INFO c.t.orders.controller.OrderController : PATCH /api/orders/1/status status=SHIPPED, correlationId=7b441d8d-...
10INFO c.t.orders.controller.OrderController : POST /api/orders userId=123, correlationId=3bf0f9c5-...
§07

五个测试怎么读How to read the five tests

@WebMvcTest(OrderController.class) 的意思是「只启动 Web 层,只加载这一个控制器」。 不会启动完整的 Spring 应用、不会加载 service 和 repository。

@MockBean OrderService 用一个假的 OrderService 替换真的。 所以 when(orderService.getAllOrders()).thenReturn(List.of())这一行是在说「如果被调用了,就返回空列表」。

这意味着测试完全不验证业务逻辑—— 它只验证「路由对不对、状态码对不对」。 所以:

  • ✅ 能抓住:路径写错、HTTP 方法写错、状态码写错。
  • ❌ 抓不到:调错了 service 方法 (比如 GET by id 里调了 getAllOrders)、 可选过滤没实现、PATCH 的非法值没挡、日志没打。

MockMvc 是「不起真实服务器的 HTTP 客户端」—— 它直接把请求喂给 Spring 的 DispatcherServlet, 所以跑得很快(整个测试 2 秒)。

注意 shouldUpdateOrderStatus 那条:when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED))指定了确切的枚举值。 如果你的转换写错了(比如没 toUpperCase而客户端传的是小写),mock 不匹配, 会返回 null → 依然是 200 → 测试还是过又一个抓不到的地方。

@WebMvcTest(OrderController.class) means “boot the web layer only, and load just this one controller”. No full Spring application, no service, no repository.

@MockBean OrderService swaps the real service for a fake one. So the line when(orderService.getAllOrders()).thenReturn(List.of()) says “if this gets called, hand back an empty list”.

Which means the tests verify no business logic whatsoever — they only verify “is the route right, is the status code right”. So:

  • ✅ Caught: a wrong path, a wrong HTTP method, a wrong status code.
  • ❌ Missed: calling the wrong service method (GET by id calling getAllOrders, say), optional filtering not implemented, PATCH not blocking bad values, no logging at all.

MockMvc is an HTTP client that never starts a real server — it feeds requests straight into Spring’s DispatcherServlet, which is why it is fast (two seconds for the whole suite).

Look closely at shouldUpdateOrderStatus: when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED)) pins the exact enum value. If your conversion is wrong (no toUpperCase while the client sends lowercase), the mock does not match, it returns null → still a 200 → the test still passes. One more thing it cannot catch.

JavaOrderControllerTest.java(全文,PROVIDED)OrderControllerTest.java (full file, PROVIDED)源项目From source
1@WebMvcTest(OrderController.class)
2class OrderControllerTest {
3 @Autowired
4 private MockMvc mockMvc;
5
6 @MockBean
7 private OrderService orderService;
8
9 @Test
10 void shouldGetAllOrders() throws Exception {
11 when(orderService.getAllOrders()).thenReturn(List.of());
12 mockMvc.perform(get("/api/orders")).andExpect(status().isOk());
13 }
14
15 @Test
16 void shouldGetOrderById() throws Exception {
17 when(orderService.getOrderById(1L)).thenReturn(new Order());
18 mockMvc.perform(get("/api/orders/1")).andExpect(status().isOk());
19 }
20
21 @Test
22 void shouldCreateOrder() throws Exception {
23 when(orderService.createOrder(any(CreateOrderRequest.class))).thenReturn(new Order());
24
25 mockMvc.perform(post("/api/orders")
26 .contentType(MediaType.APPLICATION_JSON)
27 .content("""
28 {
29 "userId": "123",
30 "items": [
31 { "productId": "prod-789", "quantity": 2 }
32 ]
33 }
34 """))
35 .andExpect(status().isCreated());
36 }
37
38 @Test
39 void shouldUpdateOrderStatus() throws Exception {
40 when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED)).thenReturn(new Order());
41
42 mockMvc.perform(patch("/api/orders/1/status")
43 .contentType(MediaType.APPLICATION_JSON)
44 .content("{\"status\":\"SHIPPED\"}"))
45 .andExpect(status().isOk());
46 }
47
48 @Test
49 void shouldDeleteOrder() throws Exception {
50 mockMvc.perform(delete("/api/orders/1"))
51 .andExpect(status().isNoContent());
52 }
53}
Source: graphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java
§08

测试之外的自检清单A self-check list for what the tests do not cover

因为测试覆盖很弱,所以必须自己补一遍。mvn spring-boot:run 起服务,然后用 curl:

第 4 条和第 5 条测试完全没覆盖, 但它们是这个端点该有的行为。

Test coverage here is weak, so you have to fill the gap by hand. Start the service with mvn spring-boot:run, then reach for curl:

Items 4 and 5 are not covered by any test, yet they are behaviour these endpoints are supposed to have.

Terminal手动自检Checking it by hand已跑通Verified
1cd java-service
2mvn spring-boot:run # 起在 8080
3
4# 1. 全部 + 可选过滤(测试没覆盖过滤)
5curl -s localhost:8080/api/orders
6curl -s "localhost:8080/api/orders?userId=123" # 应该只返回 user 123 的
7
8# 2. 单个存在 vs 不存在(测试没覆盖 404)
9curl -i -s localhost:8080/api/orders/1 # 200 + JSON
10curl -i -s localhost:8080/api/orders/999 # 404 + { timestamp, status, message }
11
12# 3. 创建(应该是 201)
13curl -i -s -X POST localhost:8080/api/orders \
14 -H 'Content-Type: application/json' \
15 -d '{"userId":"123","items":[{"productId":"prod-789","quantity":2}]}'
16
17# 4. 创建时校验失败(应该是 400,测试没覆盖)
18curl -i -s -X POST localhost:8080/api/orders \
19 -H 'Content-Type: application/json' \
20 -d '{"userId":"","items":[]}'
21
22# 5. PATCH 非法状态(应该是 400 而不是 500,测试没覆盖)
23curl -i -s -X PATCH localhost:8080/api/orders/1/status \
24 -H 'Content-Type: application/json' -d '{"status":"FLYING"}'
25
26# 6. 删除(应该是 204,空 body)
27curl -i -s -X DELETE localhost:8080/api/orders/1
28
29# 7. correlation id 透传(响应头里应该有同一个 id)
30curl -i -s -H 'X-Correlation-ID: my-trace-1' localhost:8080/api/orders
1cd java-service
2mvn spring-boot:run # starts on 8080
3
4# 1. All of them, plus the optional filter (no test covers the filter)
5curl -s localhost:8080/api/orders
6curl -s "localhost:8080/api/orders?userId=123" # should return only user 123
7
8# 2. One that exists vs one that does not (no test covers the 404)
9curl -i -s localhost:8080/api/orders/1 # 200 + JSON
10curl -i -s localhost:8080/api/orders/999 # 404 + { timestamp, status, message }
11
12# 3. Create (should be 201)
13curl -i -s -X POST localhost:8080/api/orders \
14 -H 'Content-Type: application/json' \
15 -d '{"userId":"123","items":[{"productId":"prod-789","quantity":2}]}'
16
17# 4. Create with validation failing (should be 400, no test covers it)
18curl -i -s -X POST localhost:8080/api/orders \
19 -H 'Content-Type: application/json' \
20 -d '{"userId":"","items":[]}'
21
22# 5. PATCH with an unknown status (should be 400 not 500, no test covers it)
23curl -i -s -X PATCH localhost:8080/api/orders/1/status \
24 -H 'Content-Type: application/json' -d '{"status":"FLYING"}'
25
26# 6. Delete (should be 204 with an empty body)
27curl -i -s -X DELETE localhost:8080/api/orders/1
28
29# 7. correlation id passed through (the same id should be in the response header)
30curl -i -s -H 'X-Correlation-ID: my-trace-1' localhost:8080/api/orders
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.