当前位置: 首页 > news >正文

一个测试驱动的Spring Boot应用程序开发

文章目录

  • 系统任务
  • 用户故事
  • 搭建开发环境
  • Web应用的框架
  • Spring Boot 自动配置
  • 三层架构
  • 领域建模
    • 域定义与领域驱动设计
    • 领域类
  • 业务逻辑
    • 功能
    • 随机的Challenge
    • 验证
  • 表示层
    • REST
    • Spring Boot和REST API
    • 设计API
    • 第一个控制器
    • 序列化的工作方式
    • 使用Spring Boot测试控制器
  • 小结

这里采用面向需求的方法,这样更加实用。我们不会一次性构建好所有功能,需要分解用户功能,每个功能模块都能提供价值。测试驱动的开发有利于提高用户有价值的功能。

系统任务

用户每次访问页面时,系统显示一道两位数的乘法题。用户输入别名(简称)并对计算结果进行猜测。这是基于他们只能进行心算的假设。用户发送数据后,Web页面将显示猜测是否正确。
另外,希望能保持用户的积极性,引入游戏机制。对每个正确的猜测结果,系统会给出评分,用户能在排名中看到分数,这样就可以与他人竞争。

用户故事

作为用户,想通过心算来解随机的乘法题,以锻炼自己的大脑。
为此,需要为这个Web应用框架构建一个最小框架。可以将用户故事1拆分为几个任务:

  1. 实用业务逻辑创建基本服务。
  2. 创建一个基础API以服务该服务(REST API)。
  3. 创建一个基础的Web页面,要求用户解题。

这里采用测试驱动的开发(TDD)来构建该组件的主要逻辑:生成乘法计算题,并验证用户提交的结果。

搭建开发环境

这里使用Java 21,确保下载官方版本;好的IDE便于开发Java代码,可以使用自己的IDE,没有的话,可以下载IntelliJ IDEA或Eclipse的社区版本;可以使用HTTPie快速测试Web应用程序,该工具可以与HTTP服务器进行交互,可用于Linux、macOS或Windows系统,另外,如果你是curl用户,很容易将http命令映射到curl命令。
Windows下要按照HTTPie命令行,可以使用Chocolatey,则需要先安装Chocolatey,这是一个Windows的包管理器。如果Chocolatey不能正常安装,也可以试着使用python的pip来安装,这需要先安装python。
可以从这里下载。之后,可以使用下列命令来安装:

# 安装 httpie
python -m pip install --upgrade pip wheel
python -m pip install httpie
# 升级 httpie
python -m pip install --upgrade pip wheel
python -m pip install --upgrade httpie
# 安装 httpie
choco install httpie
# 升级 httpie
choco upgrade httpie

本项目使用IDEA、maven、Java 21和Spring Boot 3.1.5。

Web应用的框架

Spring提供了构建应用程序框架的绝佳方法:Spring Initializr。这是一个Web页面,可以选择在Spring Boot项目中包含的组件和库,然后将结果压缩成zip文件供用户下载。
如果你使用IDEA或Spring Tools(可以从sping.io官方网站下载:Spring Tools)作为开发环境,已经内置了Spring Initializr支持。
创建项目时,选择依赖:Lombok,Spring Web,Validation,生成项目目录如下图所示:
项目目录
可以在IDE界面,也可以在控制台运行该应用。在项目根文件夹下使用如下命令:

./mvnw spring-boot:run

现在,就有了一个不必编写任何代码就可以运行的Spring Boot应用程序了。

Spring Boot 自动配置

在日志中,可以找到以下日志行:

INFO 55148 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)

这多亏了Spring的自动配置,当添加了Web依赖后,就可以得到一个使用Tomcat的可独立部署的Web应用程序了。
Spring Boot自动设置了库和默认配置,当依赖于这些默认配置时,可以节省很多时间。
可以想象,这种机制也适用于数据库、Web服务器、消息代理、云原生模式、安全等,在Spring Boot中,可以找到很多starter,将它们添加到依赖中,自动配置就会发挥作用,获取开箱即用的其他功能。

三层架构

多层架构使应用程序更适用于生产环境,大多数真实世界的应用程序都遵循这种架构范式。在Web应用程序中,三层设计是最受欢迎的一种,并得到了推广。

  • 客户端:负责用户界面,就是常说的前端。
  • 应用层:业务逻辑和与其进行交互的接口以及用于持久性的数据接口,就是常说的后端。
  • 数据层:如数据库、文件系统,负责持久化应用程序的数据。

这里,主要关注应用层,放大来看,通常采用三层架构:

  • 业务层:包括对域和业务细节建模的类,常分为两部分:域(实体)和提供业务逻辑的服务。
  • 表示层:这里通常用Controller类来表示,为Web客户端提供功能。REST API位于这里。
  • 数据层:负责将实体持久化存储在数据存储区中,通常是一个数据库。通常包括DAO类或存储库类,前者与直接映射到数据库某行中的对象一起使用,后者则以域为中心,因此它们可能需要将域表示转换为数据库结构。
    Spring是构建这类架构的绝佳选择,有许多开箱即用的功能,提供了三个注解,分别映射到每个层:
  • @Controller用于表示层。
  • @Service对应业务层,用于实现业务逻辑的类。
  • @Repository用于数据层,即与数据库交互的类。

领域建模

域定义与领域驱动设计

这个Web应用程序负责生成乘法题并验证用户随后的尝试。定义3个业务实体:

  • Challenge:包含乘法题的2个乘法因子。
  • User:识别试图解决Challenge的人。
  • ChallengeAttempt:表示用户为解决Challenge中的操作所做的尝试。

可对域对象及其关系进行建模,如图所示:

ChallengerAttempt User Challenger attempt corresponding

为了使DDD更清晰,引入其他与Users或Challenges相关的域。例如,可通过创建域Friends,并对用户之间的关系与互动建模来引入社交网络功能。如果将Users和Challenges这两个域混合在一起,这种演变将很难完成,因为新的域与Challenges无关。

微服务与DDD
常见的误区是每个域都必须拆分成不同的微服务,这可能导致项目的复杂性呈指数级增加。

领域类

创建Challenge,ChallengeAttempt和User类,按照域分成两个部分:Users和Challenges。创建两个包:
包

项目使用Lombok依赖,可以减少代码生成。Challenge类代码如下:

package cn.zhangjuli.multiplication.challenge;import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class Challenge {private int factorA;private int factorB;
}

ChallengeAttempt类代码如下:

package cn.zhangjuli.multiplication.challenge;import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class ChallengeAttempt {private Long id;private Long userId;private int factorA;private int factorB;private int resultAttempt;private boolean correct;
}

User类代码如下:

package cn.zhangjuli.multiplication.user;import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class User {private Long id;private String alias;
}

业务逻辑

定义了领域模型,就需要考虑业务逻辑了。

功能

需要以下功能:

  • 生成中等复杂度乘法运算的方法,所有除数在11到99之间。
  • 检查用户尝试的结果是否正确。

随机的Challenge

开始测试驱动的开发,以实现业务逻辑。首先编写一个生成随机Challenge的基本接口ChallengeGeneratorService,代码如下:

package cn.zhangjuli.multiplication.challenge;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
public interface ChallengeGeneratorService {/*** @return a randomly-generated challenge with factors between 11 and 99*/Challenge randomChallenge();
}

现在,编写该接口的空实现类ChallengeGeneratorServiceImpl,代码如下:

package cn.zhangjuli.multiplication.challenge;import org.springframework.stereotype.Service;import java.util.Random;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Service
public class ChallengeGeneratorServiceImpl implements ChallengeGeneratorService {private final Random random;ChallengeGeneratorServiceImpl() {this.random = new Random();}protected ChallengeGeneratorServiceImpl(Random random) {this.random = random;}@Overridepublic Challenge randomChallenge() {return null;}
}

为了在Spring的上下文中加载该实现,使用@Service注解,这样可通过接口将该服务类注入其他层,而不是通过实现注入服务。使用这种方式,保持了松散的耦合。现在重点放在TDD上,将randomChallenge()实现留空。
下面编写测试ChallengeGeneratorServiceTest类,代码如下:

package cn.zhangjuli.multiplication.challenge;import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;import java.util.Random;import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@ExtendWith(MockitoExtension.class)
public class ChallengeGeneratorServiceTest {private ChallengeGeneratorService challengeGeneratorService;// 使用@Spy注解,创建一个桩对象@Spyprivate Random random;// 使用@BeforEach注解,初始化测试需要的全部内容,在每次测试开始前都会这样做。@BeforeEachpublic void setUp() {challengeGeneratorService = new ChallengeGeneratorServiceImpl(random);}// 遵循BDD风格,使用given()设置前提条件,为生成11和99之间是随机数,可获得0和89之间的随机数并将其加上11。// 因此,应该使用89来调用random,以生成一个11和100之间的随机数,// 覆盖该调用,第一次调用时返回20,第二次调用时返回30。// 当调用randomChallenge()时,// 期望random返回20和30作为随机数(桩对象),并因此返回用31和41构造的Challenge对象。@Testpublic void generateRandomFactorIsBetweenExpectedLimits() {// 89 is max - min rangegiven(random.nextInt(89)).willReturn(20, 30);// when we generate a challengeChallenge challenge = challengeGeneratorService.randomChallenge();// then the challenge contains factors as exceptedthen(challenge).isEqualTo(new Challenge(31, 41));}
}

运行测试,不出所料,测试失败了,结果如下:

org.opentest4j.AssertionFailedError: 
expected: Challenge(factorA=31, factorB=41)but was: null
Expected :Challenge(factorA=31, factorB=41)
Actual   :null

现在,需要通过测试,就需要实现测试的功能,ChallengeGeneratorServiceImpl 类的完整代码如下:

package cn.zhangjuli.multiplication.challenge;import org.springframework.stereotype.Service;import java.util.Random;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Service
public class ChallengeGeneratorServiceImpl implements ChallengeGeneratorService {private final Random random;private final static int MINIMUM_FACTOR = 11;private final static int MAXIMUM_FACTOR = 100;ChallengeGeneratorServiceImpl() {this.random = new Random();}protected ChallengeGeneratorServiceImpl(Random random) {this.random = random;}private int next() {return random.nextInt(MAXIMUM_FACTOR - MINIMUM_FACTOR) + MINIMUM_FACTOR;}@Overridepublic Challenge randomChallenge() {return new Challenge(next(), next());}
}

再次运行测试,就通过了。这就是测试驱动的开发,首先设计测试,刚开始时会失败,然后,实现逻辑让测试通过。这可以让你从构建测试用例中获得最大收益,从而实现真正需要的功能。

验证

现在,需要实现一个验证用户尝试的接口,ChallengeService 类代码如下:

package cn.zhangjuli.multiplication.challenge;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
public interface ChallengeService {/*** verifies if an attempt coming from the presentation layer is correct or not.** @param resultAttempt a DTO(Data Transfer Object) object * @return the resulting ChallengeAttempt object*/ChallengeAttempt verifyAttempt(ChallengeAttemptDTO resultAttempt);
}

现在,ChallengeAttemptDTO 对象不存在,需要实现ChallengeAttemptDTO 类,这里,使用DTO对表示层所需的数据进行建模,以创建一个Attempt,Attempt没有correct字段,也不需要知道用户ID,其代码如下:

package cn.zhangjuli.multiplication.challenge;import lombok.Value;/*** Attempt coming from user* * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
// 使用@Value注解,来创建一个不可变的类,包含all-args构造方法和toString、equals和hashCode方法,
// 还将字段设置为private final的,因此不需要再进行声明。
@Value
public class ChallengeAttemptDTO {int factorA, factorB;String userAlias;int guess;
}

继续采用TDD方法,在ChallengeServiceImpl类中创建一个空逻辑,代码如下:

package cn.zhangjuli.multiplication.challenge;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
public class ChallengeServiceImpl implements ChallengeService {@Overridepublic ChallengeAttempt verifyAttempt(ChallengeAttemptDTO resultAttempt) {return null;}
}

为这个类编写一个单元测试,代码如下:

package cn.zhangjuli.multiplication.challenge;import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;import static org.assertj.core.api.BDDAssertions.then;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
public class ChallengeServiceTest {private ChallengeService challengeService;@BeforeEachpublic void setUp() {challengeService = new ChallengeServiceImpl();}@Testpublic void checkCorrectAttemptTest() {// givenChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 60, "john_doe", 3000);// whenChallengeAttempt resultAttempt = challengeService.verifyAttempt(attemptDTO);// thenthen(resultAttempt.isCorrect()).isTrue();}@Testpublic void checkWrongAttemptTest() {// givenChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 60, "john_doe", 5000);// whenChallengeAttempt resultAttempt = challengeService.verifyAttempt(attemptDTO);// thenthen(resultAttempt.isCorrect()).isFalse();}
}

50和60相乘的结果是3000,因此,第一个测试用例的结果期望是true,而第二个测试用例的结果是false。执行测试,结果不通过,产生空指针异常。
下面实现验证的逻辑,代码如下:

package cn.zhangjuli.multiplication.challenge;import cn.zhangjuli.multiplication.user.User;
import org.springframework.stereotype.Service;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Service
public class ChallengeServiceImpl implements ChallengeService {@Overridepublic ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {// Check if the attempt is correctboolean isCorrect =attemptDTO.getGuess() == attemptDTO.getFactorA() * attemptDTO.getFactorB();// We don't use identifiers for nowUser user = new User(null, attemptDTO.getUserAlias());// Builds the domain object. Null id for now.ChallengeAttempt checkedAttempt = new ChallengeAttempt(null,user.getId(),attemptDTO.getFactorA(),attemptDTO.getFactorB(),attemptDTO.getGuess(),isCorrect);return checkedAttempt;}
}

再次运行测试,可以通过测试了。
还需要创建一个User或查找一个现有的User,将该用户与新的Attempt相关联,并将其存储在数据库中。由于在这个用户故事中,Users域不需要任何业务逻辑,现在不做处理。

表示层

REST

这里采用实际软件项目中通常的做法,使用表示层:中间有一个API层。这样,可以使后端和前端完全隔离。现在最受欢迎的是REpresentational State Transfer(REST),它通常构建在HTTP之上,可以执行API操作,如GET、POST、PUT、DELETE等。
通过API传输的内容还包含多个方面:分页、空值处理、格式(如JSON)、安全性、版本控制等。

Spring Boot和REST API

使用Spring构建REST API是一项简单的任务,它提供一种专门用于构建REST控制器的模板,使用@RestController注解。
可以使用@RequestMapping注解对不同HTTP资源和映射进行建模,该注解适用于类和方法,方便构建API上下文。为了简单化,还提供了@PostMapping、@GetMapping等变体,不需要指定具体的HTTP动作。
每当要传递请求体给方法的时候,会使用@RequestBody注解。如果使用自定义类,Spring Boot会对其进行反序列化。默认情况下,Spring Boot使用JSON序列化格式。
还可以使用请求参数自定义API,并读取请求路径中的值,例如:
GET http://localhost/challenges/5?factorA=40

  • GET是HTTP动作
  • localhost是主机地址
  • /challenges/是应用程序创建的API上下文
  • /5是路径变量,这里代表id为5的Challenge对象
  • factorA=40是请求参数及其对应的值

可以创建一个控制器来处理这个请求,得到5作为路径变量challengeId的值,并获得40作为请求参数factorA的值,代码如下:

package cn.zhangjuli.multiplication.challenge;import org.springframework.web.bind.annotation.*;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@RestController
@RequestMapping("/challenges")
public class ChallengeAttemptController {@GetMapping("/{challengeId}")public Challenge getChallengeWithParam(@PathVariable("challengeId") Long id,@RequestParam("factorA") int factorA) {return null;}
}

设计API

可以根据需求来设计需要在REST API中公开的功能。

  • 一个用于获取随机、中等复杂度乘法运算的接口。
  • 一个端点,用于发送特定用户别名对给定乘法运算的猜测。

一个用于Challenge的读操作,一个用于创建Attempt的操作。请记住,这是不同的资源,将API拆分成两部分,并执行对应的操作:

  • GET /challenges/random将返回随机生成的Challenge。
  • POST /attempts/将发送Attempt以解决Challenge的端点。

这两种资源都属于Challenges域。最后还需要一个/Users映射来执行与用户相关的操作,现在不需要完成。

第一个控制器

现在,创建一个生成随机Challenge的控制器。在服务层以及实现了这个操作,只需要从控制器调用这个方法即可。代码如下:

package cn.zhangjuli.multiplication.challenge;import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;/*** This class implements a REST API to get random challenges.* * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@RestController
// 创建一个名为log的日志记录器。
@Slf4j
// 创建以ChallengeGeneratorService为参数的构造方法,参数是private final的。
// 由于Spring的依赖注入,会尝试找到实现此接口的Bean并将其连接到控制器,这种情况下,
// 将采用唯一候选的服务实现,即ChallengeGeneratorServiceImpl。
@RequiredArgsConstructor
// 所有映射方法都添加/challenges前缀
@RequestMapping("/challenges")
public class ChallengeController {private final ChallengeGeneratorService challengeGeneratorService;// 这里将处理/challenges/random上下文的GET请求。@GetMapping("/random")Challenge getRandomChallenge() {Challenge challenge = challengeGeneratorService.randomChallenge();log.info("Generating a random challenge: {}", challenge);return challenge;}
}

@RestController注解是专门用于REST控制器建模的组件,由@Controller和@ResponseBody组合而成,使用默认设置,将序列化为JSON数据。
重新运行Web应用程序,可以进行API测试。可以在IDE环境中运行,也可以在控制台中使用命令 ./mvnw spring-boot:run 运行。
现在,就可以使用HTTPie的命令行向API发出请求了,命令和结果如下:

> http localhost:8080/challenges/random
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Sun, 19 Nov 2023 10:07:35 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked{"factorA": 42,"factorB": 30
}

序列化的工作方式

Spring Boot运行时,嵌入了Tomcat服务器,有很多autoconfigure类包含在spring-boot-autoconfigure依赖中。下面了解一下它是如何工作的。
Spring Boot Web模块有许多逻辑和默认值都在WebMvcAutoConfiguration类中,该类会收集上下文中所有可用的HTTP消息转换器,以备后用。下面是其代码片段:

		@Overridepublic void configureMessageConverters(List<HttpMessageConverter<?>> converters) {this.messageConvertersProvider.ifAvailable((customConverters) -> converters.addAll(customConverters.getConverters()));}

核心spring-web包中包含HttpMessageConverter接口,该接口定义了转换器支持的媒体类型、可执行转换的类以及可执行转换的读写方法。
这些转换器从哪里来的呢?答案是来自许许多多的自动配置类,这些配置都以灵活的方式设置,方便在真实生产环境中自定义配置,例如,如果想将JSON属性命名方式从驼峰命名法(camel-case)替换为蛇形命名法(snake-case),可用在应用程序的配置中声明一个自定义的ObjectMapper,该配置会加载以替代默认配置,代码如下:

package cn.zhangjuli.multiplication;import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;@SpringBootApplication
public class MultiplicationApplication {public static void main(String[] args) {SpringApplication.run(MultiplicationApplication.class, args);}@Beanpublic ObjectMapper objectMapper() {var objectMapper = new ObjectMapper();objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SnakeCaseStrategy.INSTANCE);return objectMapper;}
}

重新执行应用程序,就会看到配置已更改为蛇形命名法的factor属性,结果:

> http localhost:8080/challenges/random
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Tue, 21 Nov 2023 02:11:20 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked{"factor_a": 88,"factor_b": 51
}

如你所见,通过覆盖Bean来定义Spring Boot配置非常容易。

使用Spring Boot测试控制器

下面将实现REST API控制器,以接收尝试,解决来自前端的交互,这里使用测试驱动的方式来完成。
首先,创建应该新的控制器,代码如下:

package cn.zhangjuli.multiplication.challenge;import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
public class ChallengeAttemptController {private final ChallengeService challengeService;
}

在Spring Boot中,有多种方法可以实现控制器测试:

  1. 不运行嵌入式服务器。可以使用不带参数的@SpringBootTest注解,更好的方法是用@WebMvcTest来指示Spring选择性地加载所需的配置,而不是整个应用程序上下文。然后,使用Spring Test模块MockMvc中包含的专用工具来模拟请求。
  2. 运行嵌入式服务器。这种情况下,使用@SpringBootTest注解,将其参数webEnvironment设置成RANDOM_PORT或DEFINED_PORT,然后,必须对服务器进行真正的HTTP调用。Spring Boot包含一个TestRestTemplate类,该类具有一些实用功能用于执行这些测试请求。想要测试一些已经自定义的Web服务器配置(如自定义的Tomcat配置)时,这是一个很好的选项。

最佳选择通常是1,并使用@WebMvcTest选择细粒度配置。不需要为每次测试花费额外的时间来启动服务器,便获得了与控制器相关的所有配置。
下面针对一个有效请求和一个无效请求分别编写测试,代码如下:

package cn.zhangjuli.multiplication.challenge;import cn.zhangjuli.multiplication.user.User;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
// 确保JUnit 5加载了Spring扩展功能,使得测试上下文可用
@ExtendWith(SpringExtension.class)
// 为测试中声明的字段配置JacksonTester类型的Bean。
@AutoConfigureJsonTesters
// 进行表示层测试。只加载控制器相关的配置:验证器、序列化程序、安全性、错误处理程序等。
@WebMvcTest(ChallengeAttemptController.class)
public class ChallengeAttemptControllerTest {// 允许模拟其他层和未经测试的Bean以帮助开发适当的单元测试,这里模拟了service Bean。@MockBeanprivate ChallengeService challengeService;@Autowiredprivate MockMvc mockMvc;@Autowiredprivate JacksonTester<ChallengeAttemptDTO> jsonRequestAttempt;@Autowiredprivate JacksonTester<ChallengeAttempt> jsonResultAttempt;@Testpublic void postValidResult() throws Exception {// givenUser user = new User(1L, "john");long attemptId = 5L;ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 70, "john", 3500);ChallengeAttempt expectedResponse = new ChallengeAttempt(attemptId, user.getId(), 50, 70, 3500, true);given(challengeService.verifyAttempt(eq(attemptDTO))).willReturn(expectedResponse);// when 用MockMvcRequestBuilders构建post请求,设置请求的内容类型为application/json,// 正文序列化成json格式的DTO,接着,调用andReturn()得到响应。MockHttpServletResponse response = mockMvc.perform(post("/attempts").contentType(MediaType.APPLICATION_JSON).content(jsonRequestAttempt.write(attemptDTO).getJson())).andReturn().getResponse();// then 验证HTTP状态码应为200 OK,且结果必须为预期响应的序列化版本。then(response.getStatus()).isEqualTo(HttpStatus.OK.value());then(response.getContentAsString()).isEqualTo(jsonResultAttempt.write(expectedResponse).getJson());}@Testpublic void postInvalidResult() throws Exception {// given an attempt with invalid input dataChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(2000, -70, "john", 1);// when 应用程序接收了无效的Attempt,不应该被传递到服务层,应该在表示层就拒绝它。MockHttpServletResponse response = mockMvc.perform(post("/attempts").contentType(MediaType.APPLICATION_JSON).content(jsonRequestAttempt.write(attemptDTO).getJson())).andReturn().getResponse();// thenthen(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());}
}

现在,可专注于测试用例并使它通过了。

  1. 有效的Attempt测试

第一个测试设置一个正确的Attempt的情景。创建一个带有正确结果的DTO,将其作为从API客户端发送的数据。使用BDDMockito的given()来指定传入的参数,当服务(被模拟的Bean)被调用,且传入的参数等于(即Mockito的eq)DTO时,将返回预期的ChallengeAttempt响应。
执行测试,会失败:

expected: 200but was: 404
Expected :200
Actual   :404

下面就来实现ChallengeAttemptController类,代码如下:

package cn.zhangjuli.multiplication.challenge;import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;/*** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
public class ChallengeAttemptController {private final ChallengeService challengeService;@PostMappingResponseEntity<ChallengeAttempt> postResult(@RequestBody ChallengeAttemptDTO challengeAttemptDTO) {return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));}
}

这是一个简单的逻辑,只需要调用服务层即可。现在再测试,即可通过。

  1. 验证控制器中的数据

第二个测试用例检查应用程序是否会拒绝接收数字为负数或超出范围的Attempt。当错误发生在客户端时,期望逻辑返回一个400 BAD REQUEST。执行测试,结果如下:

expected: 400but was: 200
Expected :400
Actual   :200

现在,看到了应用程序接收了无效的Attempt并返回了OK状态,这不是期望的。这里,在DTO类中添加用于验证的注解来表明什么是有效的输入,这些注解在jakarta.validation-api库中实现,代码如下:

package cn.zhangjuli.multiplication.challenge;import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import lombok.Value;/*** Attempt coming from user** @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>*/
// 使用@Value注解,来创建一个不可变的类,包含all-args构造方法和toString、equals和hashCode方法,
// 还将字段设置为private final的,因此不需要再进行声明。
@Value
public class ChallengeAttemptDTO {@Min(1) @Max(99)int factorA, factorB;@NotBlankString userAlias;@Positiveint guess;
}

这些约束条件生效需要通过在控制器方法参数中添加@Valid注解来实现与Spring的集成,只有添加这个注解,Spring Boot才会分析约束条件,被在参数不满足条件时,抛出异常。代码如下:

    @PostMappingResponseEntity<ChallengeAttempt> postResult(@RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));}

现在,当对象无效时,可使用自动配置来处理错误并构建预定义的响应,默认情况下,错误处理程序使用状态码400 BAD_REQUEST构造响应。
如果错误响应看不到验证信息,需要在application.properties中添加两个配置来启用,代码如下:

server.error.include-message=always
server.error.include-binding-errors=always

这些测试,会看到如下日志:

[Field error in object 'challengeAttemptDTO' on field 'factorA': rejected value [2000]; codes [Max.challengeAttemptDTO.factorA,Max.factorA,Max.int,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [challengeAttemptDTO.factorA,factorA]; arguments []; default message [factorA],99]; default message [must be less than or equal to 99]] [Field error in object 'challengeAttemptDTO' on field 'factorB': rejected value [-70]; codes [Min.challengeAttemptDTO.factorB,Min.factorB,Min.int,Min]; 

控制器中负责处理用户发送Attempt的REST API调用起作用了。再次重启应用程序,可用使用HTTPie命令调用这个新端口,像前面那样请求一个随机挑战,然后提交一个Attempt。可在控制台执行如下命令,可得到如下结果:

> http -b :8080/challenges/random
{"factorA": 68,"factorB": 87
}
> http POST :8080/attempts factorA=68 factorB=87 userAlias=john guess=5400
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Wed, 22 Nov 2023 02:38:47 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked{"correct": false,"factorA": 68,"factorB": 87,"id": null,"resultAttempt": 5400,"userId": null
}

第一个命令使用-b参数,表示仅输出响应的正文,如你所见,还可用省略localhost,HTTPie会默认使用它。
为发送Attempt,需要使用POST参数。HTTPie默认内容类型为JSON,可用简单地以key=value格式传递参数,会自动转换为JSON格式。也可用尝试提交一个无效的请求,来了解Spring Boot如何处理验证错误,命令和结果如下:

> http POST :8080/attempts factorA=68 factorB=87 userAlias=john guess=-400
HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Wed, 22 Nov 2023 02:45:07 GMT
Transfer-Encoding: chunked{"error": "Bad Request","errors": [{"arguments": [{"arguments": null,"code": "guess","codes": ["challengeAttemptDTO.guess","guess"],"defaultMessage": "guess"}],"bindingFailure": false,"code": "Positive","codes": ["Positive.challengeAttemptDTO.guess","Positive.guess","Positive.int","Positive"],"defaultMessage": "必须是正数","field": "guess","objectName": "challengeAttemptDTO","rejectedValue": -400}],"message": "Validation failed for object='challengeAttemptDTO'. Error count: 1","path": "/attempts","status": 400,"timestamp": "2023-11-22T02:45:07.992+00:00"
}

这是一个相当冗长的响应。主要原因是所有绑定错误(由验证约束条件引起的错误)都被加进错误响应中。
如果该响应发送到用户界面,需要在前端解析该JSON响应,获取无效字段,可能要显示defaultMessage字段。
更改这个默认消息非常简单,可用通过约束注解覆盖它,在ChallengeAttemptDTO类中修改注解,然后再次尝试看看,代码如下:

@Value
public class ChallengeAttemptDTO {// ...@Positive(message = "How could you possibly get a negative result here? Try again.")int guess;

小结

这里介绍了如何创建Spring Boot应用程序的框架,以及最佳实践:三层架构、领域驱动设计、测试驱动的开发、JUnit5单元测试和REST API设计。还介绍了Spring Boot中的核心功能:自动配置,从上面的例子中可以看到其神奇之处。另外,介绍了如何使用MockMvc测试控制器,来实现测试驱动的控制器的开发。

示例代码

相关文章:

一个测试驱动的Spring Boot应用程序开发

文章目录 系统任务用户故事搭建开发环境Web应用的框架Spring Boot 自动配置三层架构领域建模域定义与领域驱动设计领域类 业务逻辑功能随机的Challenge验证 表示层RESTSpring Boot和REST API设计API第一个控制器序列化的工作方式使用Spring Boot测试控制器 小结 这里采用面向需…...

简单几步,借助Aapose.Cells将 Excel XLS 转换为PPT

数据呈现是商业和学术工作的一个重要方面。通常&#xff0c;您需要将数据从一种格式转换为另一种格式&#xff0c;以创建信息丰富且具有视觉吸引力的演示文稿。当您需要在幻灯片上呈现工作表数据时&#xff0c;需要从 Excel XLS 转换为 PowerPoint 演示文稿。在这篇博文中&…...

CSS特效016:天窗扬起合上的效果

CSS常用示例100专栏目录 本专栏记录的是经常使用的CSS示例与技巧&#xff0c;主要包含CSS布局&#xff0c;CSS特效&#xff0c;CSS花边信息三部分内容。其中CSS布局主要是列出一些常用的CSS布局信息点&#xff0c;CSS特效主要是一些动画示例&#xff0c;CSS花边是描述了一些CSS…...

基于SSM的济南旅游网站设计与实现

末尾获取源码 开发语言&#xff1a;Java Java开发工具&#xff1a;JDK1.8 后端框架&#xff1a;SSM 前端&#xff1a;Vue 数据库&#xff1a;MySQL5.7和Navicat管理工具结合 服务器&#xff1a;Tomcat8.5 开发软件&#xff1a;IDEA / Eclipse 是否Maven项目&#xff1a;是 目录…...

【ArrayList是如何扩容(ArrayList、LinkedList、与Vector的区别)】

ArrayList、LinkedList、与Vector的区别 解读ArrayList 是一个可改变大小的数组LinkedList 是一个双向链表Vector 属强同步类 拓展知识面ArrayList是如何扩容&#xff1f;如何利用List实现LRU&#xff1f; 解读 List主要有ArrayList、LinkedList与Vector几种实现。这三者都实现…...

STM32_3(GPIO)

GPIO简介 GPIO&#xff08;General Purpose Input Output&#xff09;通用输入输出口8种输入输出模式输出模式可控制端口输出高电平&#xff0c;驱动LED、蜂鸣器、模拟通信协议输出时许等输入模式可读取端口的高低电平或电压&#xff0c;用于读取按键输入、外接模块电平信号输…...

【技巧】PDF文件如何编辑?

日常办公中我们经常会用到PDF文件&#xff0c;PDF具备很好的兼容性、稳定性及安全性&#xff0c;但却不容易编辑&#xff0c;那PDF要如何编辑呢&#xff1f; 如果打开PDF文件就只是只读的性质&#xff0c;说明文件是在线打开&#xff0c;或者通过PDF阅读器打开的&#xff0c;这…...

AR道具特效制作工具

AR&#xff08;增强现实&#xff09;技术已经逐渐渗透到各个行业&#xff0c;为企业带来了全新的营销方式和用户体验。在这个背景下&#xff0c;美摄科技凭借其强大的技术实力和创新精神&#xff0c;推出了一款专为企业打造的美摄AR特效制作工具&#xff0c;旨在帮助企业轻松实…...

鸿蒙4.0开发笔记之DevEco Studio页面操作router的pushUrl页面跳转与back返回上一页(五)

一、认识组件 关于HarmonyOS中ArkTS的基础组件请参见文章鸿蒙4.0开发笔记之ArkTs语言基础与基本组件结构&#xff08;四&#xff09; 二、实现页面跳转pushUrl 1、操作说明 实现页面跳转的核心便是router.pushUrl的调用&#xff0c;操作起来也很简单&#xff0c;总共就四步…...

20个CSS函数-释放设计创造力和响应能力

20个CSS函数-释放设计创造力和响应能力 CSS是网页设计的核心&#xff0c;使开发者和设计者能够制作出令人叹为观止和反应迅速的网页布局。CSS函数通过引入动态性和多功能性提升了我们的设计能力。在本文中&#xff0c;我们将开始讲解20个CSS函数。 1.rgba()&#xff1a;定义颜…...

Dubbo从入门到上天系列第十八篇:Dubbo引入注册中心简介以及DubboAdmin简要介绍,为后续详解Dubbo各种注册中心做铺垫!

一&#xff1a;Dubbo注册中心引言 1&#xff1a;什么是Dubbo的注册中心&#xff1f; Dubbo注册中心是Dubbo服务治理中极其重要的一个概念。它主要是用于对Rpc集群应用实例进行管理。 对于我们的Dubbo服务来讲&#xff0c;至少有两部分构成&#xff0c;一部分是Provider一部分是…...

CentOS8安装MySQL

选择MySQL数据库的原因&#xff1a; 1、运行速度快&#xff1b; 2、开源免费&#xff1b; 3、易学易用&#xff1b; 4、可移植性&#xff0c;能够在众多不同的系统上工作&#xff1b; 5、丰富的接口&#xff0c;提供了用于C、C等语言的API&#xff1b; 6、支持查询语言&#xf…...

Java集合拓展01

1、List&#xff0c;Set&#xff0c;Map三者的区别 List&#xff1a;一个有序&#xff08;元素存入集合的顺序和取出的顺序一致&#xff09;容器&#xff0c;元素可以重复&#xff0c;可以插入多个null元素&#xff0c;元素都有索引。常用的实现类有 ArrayList、LinkedList 和…...

【Django使用】md文档10大模块第5期:Django数据库增删改查和Django视图

Django的主要目的是简便、快速的开发数据库驱动的网站。它强调代码复用&#xff0c;多个组件可以很方便的以"插件"形式服务于整个框架&#xff0c;Django有许多功能强大的第三方插件&#xff0c;你甚至可以很方便的开发出自己的工具包。这使得Django具有很强的可扩展…...

在AWS VPC中运行Nagios检查时指定自定义DNS解析器的选项

在AWS VPC中运行Nagios检查&#xff0c;并希望能够指定自定义DNS解析器来处理请求。我想使用Python requests库来实现这个目标。 根据问题描述&#xff0c;您想在AWS VPC中运行Nagios检查&#xff0c;并希望使用Python的requests库来指定自定义DNS解析器。 要解决这个问题&…...

【uniapp】触底加载事件 onReachBottom 不生效

我遇到的情况有&#xff1a; 检查 css 是不是写了overflow-y: scroll; 或 overflow: auto; 是不是用了局部滚动 <scroll-view></scroll-view>&#xff1b; 注意&#xff1a; onReachBottom 和 scroll-view 是冲突的...

Vue3简单使用(一) --- 环境搭建

node版本管理工具nvm&#xff0c;nvm list、nvm use 14.18.0 可以简单启动服务器&#xff1a;npx serve 安装包&#xff1a;npm install xx1 xx2&#xff0c;npm install -D xx3 xx4 vue提供了多个版本 传统项目引入 全局构建版本 <div id"app"><button …...

陪玩圈子系统APP小程序H5,详细介绍,源码交付,支持二开!

陪玩圈子系统&#xff0c;页面展示&#xff0c;源码交付&#xff0c;支持二开&#xff01; 陪玩后端下载地址&#xff1a;电竞开黑陪玩系统小程序&#xff0c;APP&#xff0c;H5: 本系统是集齐开黑&#xff0c;陪玩&#xff0c;陪聊于一体的专业APP&#xff0c;小程序&#xff…...

目标检测原理

一、什么是目标检测 目标检测的任务是找出图像中所有感兴趣的目标&#xff08;物体&#xff09;&#xff0c;确定他们的类别和位置&#xff0c;是计算机视觉领域的核心问题之一。由于各类物体有不同的外观、形状、姿态&#xff0c;再加上光照、遮挡等因素的干扰&#xff0c;目…...

2、数仓理论概述与相关概念

1、问&#xff1a;数据仓库 建设过程中 经常会遇到那些问题&#xff1f; 模型(逻辑)重复建设 数据不一致性 维度不一致&#xff1a;命名、维度属性值、维度定义 指标不一致&#xff1a;命名、计算口径 数据不规范(字段命名、表名、分层、主题命名规范) 2、OneData数据建设核心方…...

docker详细操作--未完待续

docker介绍 docker官网: Docker&#xff1a;加速容器应用程序开发 harbor官网&#xff1a;Harbor - Harbor 中文 使用docker加速器: Docker镜像极速下载服务 - 毫秒镜像 是什么 Docker 是一种开源的容器化平台&#xff0c;用于将应用程序及其依赖项&#xff08;如库、运行时环…...

CVPR 2025 MIMO: 支持视觉指代和像素grounding 的医学视觉语言模型

CVPR 2025 | MIMO&#xff1a;支持视觉指代和像素对齐的医学视觉语言模型 论文信息 标题&#xff1a;MIMO: A medical vision language model with visual referring multimodal input and pixel grounding multimodal output作者&#xff1a;Yanyuan Chen, Dexuan Xu, Yu Hu…...

突破不可导策略的训练难题:零阶优化与强化学习的深度嵌合

强化学习&#xff08;Reinforcement Learning, RL&#xff09;是工业领域智能控制的重要方法。它的基本原理是将最优控制问题建模为马尔可夫决策过程&#xff0c;然后使用强化学习的Actor-Critic机制&#xff08;中文译作“知行互动”机制&#xff09;&#xff0c;逐步迭代求解…...

从零实现富文本编辑器#5-编辑器选区模型的状态结构表达

先前我们总结了浏览器选区模型的交互策略&#xff0c;并且实现了基本的选区操作&#xff0c;还调研了自绘选区的实现。那么相对的&#xff0c;我们还需要设计编辑器的选区表达&#xff0c;也可以称为模型选区。编辑器中应用变更时的操作范围&#xff0c;就是以模型选区为基准来…...

(二)TensorRT-LLM | 模型导出(v0.20.0rc3)

0. 概述 上一节 对安装和使用有个基本介绍。根据这个 issue 的描述&#xff0c;后续 TensorRT-LLM 团队可能更专注于更新和维护 pytorch backend。但 tensorrt backend 作为先前一直开发的工作&#xff0c;其中包含了大量可以学习的地方。本文主要看看它导出模型的部分&#x…...

P3 QT项目----记事本(3.8)

3.8 记事本项目总结 项目源码 1.main.cpp #include "widget.h" #include <QApplication> int main(int argc, char *argv[]) {QApplication a(argc, argv);Widget w;w.show();return a.exec(); } 2.widget.cpp #include "widget.h" #include &q…...

Java面试专项一-准备篇

一、企业简历筛选规则 一般企业的简历筛选流程&#xff1a;首先由HR先筛选一部分简历后&#xff0c;在将简历给到对应的项目负责人后再进行下一步的操作。 HR如何筛选简历 例如&#xff1a;Boss直聘&#xff08;招聘方平台&#xff09; 直接按照条件进行筛选 例如&#xff1a…...

均衡后的SNRSINR

本文主要摘自参考文献中的前两篇&#xff0c;相关文献中经常会出现MIMO检测后的SINR不过一直没有找到相关数学推到过程&#xff0c;其中文献[1]中给出了相关原理在此仅做记录。 1. 系统模型 复信道模型 n t n_t nt​ 根发送天线&#xff0c; n r n_r nr​ 根接收天线的 MIMO 系…...

基于TurtleBot3在Gazebo地图实现机器人远程控制

1. TurtleBot3环境配置 # 下载TurtleBot3核心包 mkdir -p ~/catkin_ws/src cd ~/catkin_ws/src git clone -b noetic-devel https://github.com/ROBOTIS-GIT/turtlebot3.git git clone -b noetic https://github.com/ROBOTIS-GIT/turtlebot3_msgs.git git clone -b noetic-dev…...

AGain DB和倍数增益的关系

我在设置一款索尼CMOS芯片时&#xff0c;Again增益0db变化为6DB&#xff0c;画面的变化只有2倍DN的增益&#xff0c;比如10变为20。 这与dB和线性增益的关系以及传感器处理流程有关。以下是具体原因分析&#xff1a; 1. dB与线性增益的换算关系 6dB对应的理论线性增益应为&…...