背景

本文是《Java 后端从小白到大神》修仙系列之框架学习,Java框架之Spring框架第五篇。本篇文章主要聊Java框架,那么必然从Spring框架开始,可以说Spring框架是Java企业级开发的基石,若想详细学习请点击首篇博文,我们现在开始吧。

文章概览

  1. Spring 测试框架

Spring 测试框架

在Spring应用开发中,测试是不可或缺的一环。Spring框架提供了强大的测试支持,即 Spring TestContext Framework,它能够帮助我们方便地进行单元测试、集成测试以及各种切片测试。本篇文章将直接通过项目演示测试框架的实际应用。

Spring 测试核心注解概览

在深入项目代码之前,我们先了解几个Spring测试中常用的核心注解,它们是构建高效测试的关键:

  • @SpringBootTest: 这是Spring Boot测试最常用的注解,用于加载完整的Spring应用程序上下文。它会启动一个内嵌的服务器(如Tomcat),或者模拟一个Web环境,从而进行端到端的集成测试。
  • @DataJpaTest: 这是一个专注于JPA组件的测试注解。它会自动配置一个内存数据库,并只加载与JPA相关的Spring Bean,非常适合测试Repository层。
  • @AutoConfigureMockMvc: 与@SpringBootTest@WebMvcTest配合使用,自动配置MockMvc实例。MockMvc允许我们在不启动实际HTTP服务器的情况下,对Spring MVC控制器执行HTTP请求,从而进行Web层测试。
  • @MockBean: 用于在Spring应用程序上下文中为Bean创建Mockito模拟(mock)或间谍(spy)对象。这在进行“切片测试”(如只测试Web层而隔离服务层)时非常有用,可以控制被依赖组件的行为。
  • @ActiveProfiles("profileName"): 用于指定当前测试激活的Spring Profile。这允许我们在测试中使用特定的配置(例如,为测试环境配置不同的数据库)。

1. Spring TestContext Framework(集成测试)

我们将通过一个实际项目来演示Spring测试框架。这里包含了不同层次的测试,它们涵盖了从Web层到数据持久层的集成测试,以及业务逻辑层的测试。

项目结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
springtest/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/yutao/demo/
    │   │   ├── config/AppConfig.java
    │   │   ├── controller/UserController.java
    │   │   ├── entity/User.java
    │   │   ├── repository/UserRepository.java
    │   │   ├── service/UserService.java
    │   │   └── DemoApplication.java
    │   └── resources/application.properties
    └── test/
        ├── java/com/yutao/demo/
        │   ├── controller/UserControllerIntegrationTest.java
        │   ├── repository/UserRepositoryIntegrationTest.java
        │   └── service/UserServiceIntegrationTest.java
        └── resources/application-test.properties

示例:main/java/com/yutao/demo/

项目代码
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
package com.yutao.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration // ① 告诉Spring这是一个配置类
public class AppConfig {

    @Profile("dev") // ② 这个内部配置类只在 "dev" profile 激活时才被Spring处理
    @Configuration // ③ 内部类也可以是配置类
    static class DevConfig {
        // 比如,可以在这里定义一个专门用于开发环境的 DataSource Bean
        // @Bean
        // public DataSource devDataSource() { return new H2DataSource(); }
    }

    @Profile("prod") // ④ 这个内部配置类只在 "prod" profile 激活时才被Spring处理
    @Configuration
    static class ProdConfig {
        // 比如,可以在这里定义一个专门用于生产环境的 DataSource Bean
        // @Bean
        // public DataSource prodDataSource() { return new MySQLDataSource(); }
    }
}

// 分割线

package com.yutao.demo.controller;

import com.yutao.demo.entity.User;
import com.yutao.demo.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
        User createdUser = userService.createUser(user);
        return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        return userService.getUserById(id)
                .map(user -> new ResponseEntity<>(user, HttpStatus.OK))
                .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }

    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        List<User> users = userService.getAllUsers();
        return new ResponseEntity<>(users, HttpStatus.OK);
    }

    @PutMapping("/{id}")
    public ResponseEntity<User> updateUser(@PathVariable Long id, @Valid @RequestBody User userDetails) {
        try {
            User updatedUser = userService.updateUser(id, userDetails);
            return new ResponseEntity<>(updatedUser, HttpStatus.OK);
        } catch (RuntimeException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        try {
            userService.deleteUser(id);
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        } catch (RuntimeException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }
}

// 分割线

package com.yutao.demo.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Entity
@Table(name = "users") // Good practice to explicitly name your table
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Name is mandatory")
    @Size(max = 100)
    private String name;

    @NotBlank(message = "Email is mandatory")
    @Email(message = "Email should be valid")
    @Column(unique = true) // Email should be unique
    private String email;

    // Default constructor for JPA
    public User() {
    }

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // Getters and Setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", email='" + email + '\'' +
                '}';
    }
}

// 分割线
package com.yutao.demo.repository;

import com.yutao.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}

// 分割线
package com.yutao.demo.service;

import com.yutao.demo.entity.User;
import com.yutao.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public User createUser(User user) {
        // In a real application, you might add business logic here,
        // e.g., check if email already exists before saving.
        return userRepository.save(user);
    }

    @Transactional(readOnly = true)
    public Optional<User> getUserById(Long id) {
        return userRepository.findById(id);
    }

    @Transactional(readOnly = true)
    public Optional<User> getUserByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    @Transactional(readOnly = true)
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    @Transactional
    public User updateUser(Long id, User userDetails) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("User not found with id " + id));
        user.setName(userDetails.getName());
        user.setEmail(userDetails.getEmail());
        return userRepository.save(user);
    }

    @Transactional
    public void deleteUser(Long id) {
        if (!userRepository.existsById(id)) {
            throw new RuntimeException("User not found with id " + id);
        }
        userRepository.deleteById(id);
    }
}

// 分割线
package com.yutao.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

     public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

spring.datasource.url=jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

示例:Web 层测试 (MockMvc 示例)

本部分通过MockMvc演示Web层(Controller)的测试。这类测试更侧重于验证HTTP请求和响应,并且通过@MockBean隔离了业务逻辑层,使其更接近“切片测试”或“狭义集成测试”。

测试代码
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
package com.yutao.demo.controller;

import com.yutao.demo.entity.User;
import com.yutao.demo.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

// @SpringBootTest 加载完整的 Spring 应用程序上下文。
// webEnvironment = SpringBootTest.WebEnvironment.MOCK 使用模拟的 servlet 环境,不启动实际的 HTTP 服务器。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc // 为测试 MVC 控制器自动配置 MockMvc
@ActiveProfiles("test") // 激活 "test" profile,虽然对于纯控制器测试(因为 MockBean)不是严格必需,但为了保持一致性可以保留
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc; // 用于在测试环境中执行 HTTP 请求

    @Autowired
    private ObjectMapper objectMapper; // 用于将对象转换为 JSON

    // @MockBean 将实际的 UserService bean 替换为一个 Mockito 模拟对象。
    // 这允许我们控制服务层的行为,并隔离控制器测试,使其不依赖于真实的业务逻辑和数据库操作。
    @MockBean
    private UserService userService;

    @Test
    void testCreateUser() throws Exception {
        User newUser = new User("John Doe", "[email protected]");
        newUser.setId(1L); // 模拟服务/数据库分配的 ID

        // Given (预设): 定义当调用 createUser 方法时,模拟的 UserService 的行为
        when(userService.createUser(any(User.class))).thenReturn(newUser);

        // When (执行) & Then (断言): 执行 HTTP POST 请求并断言响应
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(new User("John Doe", "[email protected]"))))
                .andExpect(status().isCreated()) // 期望 HTTP 201 Created 状态码
                .andExpect(jsonPath("$.id", is(1))) // 期望响应 JSON 中 id 字段为 1
                .andExpect(jsonPath("$.name", is("John Doe"))) // 期望 name 字段为 "John Doe"
                .andExpect(jsonPath("$.email", is("[email protected]"))); // 期望 email 字段为 "[email protected]"

        // Verify (验证): 验证 userService.createUser 方法被调用了一次
        verify(userService, times(1)).createUser(any(User.class));
    }

    @Test
    void testGetUserByIdFound() throws Exception {
        User user = new User("Jane Smith", "[email protected]");
        user.setId(2L);

        // Given (预设): 定义模拟的 UserService 的行为
        when(userService.getUserById(2L)).thenReturn(Optional.of(user));

        // When (执行) & Then (断言): 执行 HTTP GET 请求
        mockMvc.perform(get("/api/users/{id}", 2L)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()) // 期望 HTTP 200 OK 状态码
                .andExpect(jsonPath("$.id", is(2))) // 期望响应 JSON 中 id 字段为 2
                .andExpect(jsonPath("$.name", is("Jane Smith"))); // 期望 name 字段为 "Jane Smith"

        verify(userService, times(1)).getUserById(2L); // 验证 userService.getUserById 方法被调用了一次
    }

    @Test
    void testGetUserByIdNotFound() throws Exception {
        // Given (预设): 定义当用户未找到时,模拟的 UserService 的行为
        when(userService.getUserById(anyLong())).thenReturn(Optional.empty());

        // When (执行) & Then (断言): 执行 HTTP GET 请求
        mockMvc.perform(get("/api/users/{id}", 99L)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound()); // 期望 HTTP 404 Not Found 状态码

        verify(userService, times(1)).getUserById(99L); // 验证 userService.getUserById 方法被调用了一次
    }

    @Test
    void testGetAllUsers() throws Exception {
        User user1 = new User("Alice", "[email protected]");
        user1.setId(1L);
        User user2 = new User("Bob", "[email protected]");
        user2.setId(2L);
        List<User> users = Arrays.asList(user1, user2);

        // Given (预设): 定义模拟的 UserService 的行为
        when(userService.getAllUsers()).thenReturn(users);

        // When (执行) & Then (断言): 执行 HTTP GET 请求
        mockMvc.perform(get("/api/users")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()) // 期望 HTTP 200 OK
                .andExpect(jsonPath("$", hasSize(2))) // 期望 JSON 数组的大小为 2
                .andExpect(jsonPath("$[0].name", is("Alice"))) // 期望第一个元素的 name 字段为 "Alice"
                .andExpect(jsonPath("$[1].name", is("Bob"))); // 期望第二个元素的 name 字段为 "Bob"

        verify(userService, times(1)).getAllUsers(); // 验证 userService.getAllUsers 方法被调用了一次
    }

    @Test
    void testUpdateUser() throws Exception {
        User existingUser = new User("Old Name", "[email protected]");
        existingUser.setId(1L);
        User updatedDetails = new User("New Name", "[email protected]"); // 待更新的用户详情
        User updatedUser = new User("New Name", "[email protected]"); // 模拟更新后的用户对象
        updatedUser.setId(1L);

        // Given (预设): 定义模拟的 UserService 的行为
        when(userService.updateUser(eq(1L), any(User.class))).thenReturn(updatedUser);

        // When (执行) & Then (断言): 执行 HTTP PUT 请求
        mockMvc.perform(put("/api/users/{id}", 1L)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(updatedDetails)))
                .andExpect(status().isOk()) // 期望 HTTP 200 OK
                .andExpect(jsonPath("$.id", is(1))) // 期望响应 JSON 中 id 字段为 1
                .andExpect(jsonPath("$.name", is("New Name"))) // 期望 name 字段为 "New Name"
                .andExpect(jsonPath("$.email", is("[email protected]"))); // 期望 email 字段为 "[email protected]"

        verify(userService, times(1)).updateUser(eq(1L), any(User.class)); // 验证 userService.updateUser 方法被调用了一次
    }

    @Test
    void testUpdateUserNotFound() throws Exception {
        User updatedDetails = new User("Non Existent", "[email protected]");

        // Given (预设): 定义当用户未找到时,模拟的 UserService 抛出异常
        when(userService.updateUser(eq(99L), any(User.class)))
                .thenThrow(new RuntimeException("User not found"));

        // When (执行) & Then (断言): 执行 HTTP PUT 请求
        mockMvc.perform(put("/api/users/{id}", 99L)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(updatedDetails)))
                .andExpect(status().isNotFound()); // 期望 HTTP 404 Not Found 状态码

        verify(userService, times(1)).updateUser(eq(99L), any(User.class)); // 验证 userService.updateUser 方法被调用了一次
    }

    @Test
    void testDeleteUser() throws Exception {
        // Given (预设): 定义模拟的 UserService 的行为,不执行任何操作(表示成功删除)
        doNothing().when(userService).deleteUser(1L);

        // When (执行) & Then (断言): 执行 HTTP DELETE 请求
        mockMvc.perform(delete("/api/users/{id}", 1L)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNoContent()); // 期望 HTTP 204 No Content 状态码

        verify(userService, times(1)).deleteUser(1L); // 验证 userService.deleteUser 方法被调用了一次
    }

    @Test
    void testDeleteUserNotFound() throws Exception {
        // Given (预设): 定义当用户未找到时,模拟的 UserService 抛出异常
        doThrow(new RuntimeException("User not found")).when(userService).deleteUser(99L);

        // When (执行) & Then (断言): 执行 HTTP DELETE 请求
        mockMvc.perform(delete("/api/users/{id}", 99L)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound()); // 期望 HTTP 404 Not Found 状态码

        verify(userService, times(1)).deleteUser(99L); // 验证 userService.deleteUser 方法被调用了一次
    }
}

示例:数据持久层测试 (@DataJpaTest 示例)

这类测试专注于JPA Repository层的行为,确保数据访问逻辑正确。@DataJpaTest默认会使用内存数据库以加快测试速度,但这里为了演示与实际MySQL的集成,我们通过@ActiveProfiles("test")激活了配置在application-test.properties中的MySQL数据库。

测试代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package com.yutao.demo.repository;

import com.yutao.demo.entity.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

// @DataJpaTest 提供了一个专注于 JPA 组件的测试切片。
// 它默认会自动配置一个内存数据库。
// 为了使用我们配置的 MySQL 数据库,我们激活 'test' profile,该 profile 会加载 application-test.properties。
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test") // 激活 test profile,因为它会加载 MySQL 配置
class UserRepositoryIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    // 注入 JdbcTemplate
    @Autowired
    private JdbcTemplate jdbcTemplate;

    // 在每个测试方法运行前,确保数据表是干净的,并且插入测试数据
    @BeforeEach
    void setup() {
        // 清理表,确保每次测试都是干净的开始
        jdbcTemplate.execute("DELETE FROM users");
        // 插入测试数据。JdbcTemplate 的 execute 默认是自动提交的,如果不是在一个事务中。
        // 在 DataJpaTest 环境中,BeforeEach 默认运行在一个事务中,但 JdbcTemplate 通常会绕过 JPA 事务。
        // 为了确保可见性,最好显式提交。
        jdbcTemplate.execute("INSERT INTO users (name, email) VALUES ('Test User', '[email protected]')");
        // 确保数据被提交,对于 JdbcTemplate 来说,通常不需要显式commit,但为了安全考虑,可以加。
        // 实际上,如果 DataJpaTest 有一个正在进行的事务,JdbcTemplate 也会加入该事务,
        // 所以最可靠的是让 BeforeEach 在一个非事务环境下运行,或者强制刷新/提交。
        // 但通常 JdbcTemplate 操作比 JPA 更“即时”可见。
    }

    @Test
    void testSaveUser() {
        User user = new User("John Doe", "[email protected]");
        User savedUser = userRepository.save(user); // 保存用户

        // 断言保存的用户不为空,ID 不为空,并且名称和邮箱与预期相符
        assertThat(savedUser).isNotNull();
        assertThat(savedUser.getId()).isNotNull();
        assertThat(savedUser.getName()).isEqualTo("John Doe");
        assertThat(savedUser.getEmail()).isEqualTo("[email protected]");

        // 从数据库中根据 ID 查找用户,并验证其存在且邮箱正确
        Optional<User> foundUser = userRepository.findById(savedUser.getId());
        assertThat(foundUser).isPresent(); // 验证用户存在
        assertThat(foundUser.get().getEmail()).isEqualTo("[email protected]"); // 验证邮箱
    }

    @Test
    void testFindUserByEmail() {
        User user = new User("Jane Smith", "[email protected]");
        userRepository.save(user); // 保存用户

        // 根据邮箱查找用户,并验证其存在且名称正确
        Optional<User> foundUser = userRepository.findByEmail("[email protected]");
        assertThat(foundUser).isPresent(); // 验证用户存在
        assertThat(foundUser.get().getName()).isEqualTo("Jane Smith"); // 验证名称
    }

    @Test
    void testDeleteUser() {
        // 由于 BeforeEach 已经插入了数据,直接查找
        Optional<User> userToDelete = userRepository.findByEmail("[email protected]");
        assertThat(userToDelete).isPresent(); // 期望能找到数据
        userRepository.deleteById(userToDelete.get().getId());
        userRepository.flush(); // 强制将删除操作同步到数据库
        Optional<User> deletedUser = userRepository.findByEmail("[email protected]");
        assertThat(deletedUser).isNotPresent(); // 期望数据已被删除
    }

    @Test
    void testUpdateUser() {
        User user = new User("Alice", "[email protected]");
        User savedUser = userRepository.save(user); // 保存初始用户

        // 更新用户名称和邮箱
        savedUser.setName("Alice Updated");
        savedUser.setEmail("[email protected]");
        User updatedUser = userRepository.save(savedUser); // 保存更新后的用户

        // 断言更新后的用户的名称和邮箱与预期相符
        assertThat(updatedUser.getName()).isEqualTo("Alice Updated");
        assertThat(updatedUser.getEmail()).isEqualTo("[email protected]");
    }
}

示例:业务逻辑层测试 (@SpringBootTest 示例)

服务层的测试通常需要更完整的Spring上下文,因为它们可能依赖于多个Bean(如Repository)。这里我们使用@SpringBootTest加载完整的上下文,并利用@Transactional注解确保测试的数据操作在事务中执行并在测试结束后回滚,保持数据库的清洁状态。

测试代码
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
package com.yutao.demo.service;

import com.yutao.demo.entity.User;
import com.yutao.demo.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

// @SpringBootTest 加载完整的 Spring 应用程序上下文。
// 我们激活 'test' profile 以使用 application-test.properties 中配置的 MySQL 数据库。
@SpringBootTest
@ActiveProfiles("test") // 激活 "test" profile
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService; // 我们正在测试真实的 UserService

    @Autowired
    private UserRepository userRepository; // 用于直接清理数据库数据,以确保测试隔离性

    @BeforeEach
    void setUp() {
        // 在每个测试方法运行前清理数据,以确保测试隔离性
        userRepository.deleteAll();
    }

    @Test
    @Transactional // 确保测试在事务中运行并回滚,保持数据库状态清洁
    void testCreateUser() {
        User user = new User("Robert", "[email protected]");
        User createdUser = userService.createUser(user); // 调用 UserService 创建用户

        // 断言创建的用户不为空,ID 不为空,并且名称和邮箱与预期相符
        assertThat(createdUser).isNotNull();
        assertThat(createdUser.getId()).isNotNull();
        assertThat(createdUser.getName()).isEqualTo("Robert");
        assertThat(createdUser.getEmail()).isEqualTo("[email protected]");

        // 验证用户确实已存在于数据库中
        Optional<User> foundUser = userRepository.findById(createdUser.getId());
        assertThat(foundUser).isPresent(); // 验证用户存在
        assertThat(foundUser.get().getEmail()).isEqualTo("[email protected]"); // 验证邮箱
    }

    @Test
    @Transactional // 确保测试在事务中运行并回滚
    void testGetUserById() {
        User user = new User("Alice", "[email protected]");
        User savedUser = userRepository.save(user); // 直接保存用户到数据库

        Optional<User> foundUser = userService.getUserById(savedUser.getId()); // 调用 UserService 根据 ID 获取用户
        assertThat(foundUser).isPresent(); // 验证用户存在
        assertThat(foundUser.get().getName()).isEqualTo("Alice"); // 验证名称
    }

    @Test
    @Transactional // 确保测试在事务中运行并回滚
    void testGetAllUsers() {
        userRepository.save(new User("User1", "[email protected]")); // 保存第一个用户
        userRepository.save(new User("User2", "[email protected]")); // 保存第二个用户

        List<User> users = userService.getAllUsers(); // 调用 UserService 获取所有用户
        assertThat(users).hasSize(2); // 验证返回的用户列表大小为 2
        // 验证用户列表中包含预期的名称(顺序不重要)
        assertThat(users).extracting(User::getName).containsExactlyInAnyOrder("User1", "User2");
    }

    @Test
    @Transactional // 确保测试在事务中运行并回滚
    void testUpdateUser() {
        User user = new User("Update Me", "[email protected]");
        User savedUser = userRepository.save(user); // 保存初始用户
        User updatedDetails = new User("Updated Name", "[email protected]"); // 待更新的用户详情

        User updatedUser = userService.updateUser(savedUser.getId(), updatedDetails); // 调用 UserService 更新用户

        // 断言更新后的用户的名称和邮箱与预期相符
        assertThat(updatedUser.getName()).isEqualTo("Updated Name");
        assertThat(updatedUser.getEmail()).isEqualTo("[email protected]");

        // 在数据库中验证更新
        Optional<User> foundUser = userRepository.findById(savedUser.getId());
        assertThat(foundUser).isPresent(); // 验证用户存在
        assertThat(foundUser.get().getName()).isEqualTo("Updated Name"); // 验证名称已更新
    }

    @Test
    @Transactional // 确保测试在事务中运行并回滚
    void testDeleteUser() {
        User user = new User("Delete Me", "[email protected]");
        User savedUser = userRepository.save(user); // 保存用户

        userService.deleteUser(savedUser.getId()); // 调用 UserService 删除用户

        // 验证用户已从数据库中删除
        Optional<User> foundUser = userRepository.findById(savedUser.getId());
        assertThat(foundUser).isNotPresent(); // 验证用户不存在
    }

    @Test
    @Transactional // 确保测试在事务中运行并回滚
    void testDeleteNonExistentUserThrowsException() {
        // 尝试删除一个不存在的用户,并断言会抛出 RuntimeException
        assertThrows(RuntimeException.class, () -> userService.deleteUser(999L));
    }
}

// 分割线
# MySQL Test Database Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA/Hibernate 测试配置
# create-drop: 在启动时创建 schema在关闭时删除 schema非常适合测试
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

3.2 Mockito(单元测试)

Mockito 是一个流行的 Java 模拟(mocking)框架。隔离单元: 在测试服务层方法时,需要实际访问数据库。Mockito 则能够“模拟”(模拟)UserRepository 的行为。控制依赖: 可以模拟的依赖,返回特定的值或抛出异常,从而测试各种场景(例如,userRepository.findById() 返回空 Optional 时会发生什么,或者 userService.saveUser() 遇到错误时会怎样)。验证交互: Mockito 可以验证模拟对象上的方法是否被调用了特定次数,或者是否使用了特定参数。Mock 对象,替代真实依赖,控制返回值和行为。行为验证,使用 verify() 确认方法调用次数和参数。无需容器启动,快速执行,适合纯逻辑测试。 常用注解

示例:UserService 单元测试

UserService 单元测试
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package com.yutao.demo.service;

import com.yutao.demo.entity.User;
import com.yutao.demo.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
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 java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class) // 为 JUnit 5 启用 Mockito 注解
class UserServiceTest {

    @Mock // 创建 UserRepository 的模拟实例
    private UserRepository userRepository;

    @InjectMocks // 将模拟对象注入到 UserService 中(被测试的类)
    private UserService userService;

    private User testUser;

    @BeforeEach
    void setUp() {
        // 初始化一个通用的测试用户以保持一致性
        testUser = new User(1L, "Test User", "[email protected]");
    }

    @Test
    void testCreateUser_Success() {
        // 定义模拟 userRepository 的行为
        when(userRepository.save(any(User.class))).thenReturn(testUser);

        User createdUser = userService.createUser(new User("Test User", "[email protected]"));

        // 验证结果
        assertNotNull(createdUser);
        assertEquals(1L, createdUser.getId());
        assertEquals("Test User", createdUser.getName());
        assertEquals("[email protected]", createdUser.getEmail());

        // 验证与模拟的交互
        verify(userRepository, times(1)).save(any(User.class));
    }

    @Test
    void testGetUserById_Found() {
        // 模拟行为:当 findById 以 1L 调用时,返回 testUser
        when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));

        Optional<User> foundUser = userService.getUserById(1L);

        // 断言
        assertTrue(foundUser.isPresent());
        assertEquals(testUser.getName(), foundUser.get().getName());

        // 验证交互
        verify(userRepository, times(1)).findById(1L);
    }

    @Test
    void testGetUserById_NotFound() {
        // 模拟行为:当 findById 以 2L 调用时,返回空 Optional
        when(userRepository.findById(2L)).thenReturn(Optional.empty());

        Optional<User> foundUser = userService.getUserById(2L);

        // 断言
        assertFalse(foundUser.isPresent());

        // 验证交互
        verify(userRepository, times(1)).findById(2L);
    }

    @Test
    void testGetAllUsers() {
        User user2 = new User(2L, "Jane Doe", "[email protected]");
        when(userRepository.findAll()).thenReturn(Arrays.asList(testUser, user2));

        List<User> users = userService.getAllUsers();

        assertNotNull(users);
        assertEquals(2, users.size());
        verify(userRepository, times(1)).findAll();
    }

    @Test
    void testDeleteUser_Success() {
        when(userRepository.existsById(1L)).thenReturn(true);
        doNothing().when(userRepository).deleteById(1L); // void 方法使用 doNothing().when() 模拟

        userService.deleteUser(1L);

        verify(userRepository, times(1)).deleteById(1L); // 验证实际的删除调用
    }

    @Test
    void testDeleteUser_NotFound() {
        when(userRepository.existsById(2L)).thenReturn(false);

        // 期望抛出异常
        assertThrows(RuntimeException.class, () -> userService.deleteUser(2L));

        verify(userRepository, never()).deleteById(anyLong()); // 确保没有调用 delete
    }
}

示例:Spring Boot Test + MockBean

Spring Boot Test + MockBean 测试
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 在 Spring Boot 项目中,可使用 `@MockBean` 替换容器中的 Bean,配合 `@SpringBootTest` 或 `@WebMvcTest` 进行切片测试。

@SpringBootTest
public class UserControllerSliceTest {
    @Autowired
    private MockMvc mvc;

    @MockBean
    private UserService userService;

    @Test
    void testCreateUser() throws Exception {
        when(userService.register("Dave")).thenReturn(new User(2L, "Dave", LocalDateTime.now()));

        mvc.perform(post("/users").param("username", "Dave"))
           .andExpect(status().isCreated());
    }
}

Mockito 常用测试方法

  • when().thenReturn()

    • 用途:用于模拟有返回值的方法,告诉 Mockito,当模拟对象的某个方法被调用时,返回一个预设的值。
    • 语法:when(mockObject.method(parameters)).thenReturn(returnValue);
    • 语法含义:当 mockObject 的 method 方法以 parameters 作为参数被调用时,不要执行其真实逻辑,而是直接返回 returnValue。
    • 示例:when(myDependency.fetchData(“testId”)).thenReturn(“actual data”),当其他地方调用fetchData方法时,返回"actual data"。
    • 示例含义:当模拟对象 myDependency 的 fetchData 方法被调用,并且传入的参数是 “testId” 时,请直接返回字符串 “actual data”,而不要去执行 fetchData 内部的任何真实逻辑。
  • doNothing().when()

    • 用途:用于模拟没有返回值(void 类型)的方法。告诉 Mockito,当模拟对象的某个 void 方法被调用时,什么也不做(即不执行实际代码,也不抛出异常)。
    • 语法:doNothing().when(mockObject).voidMethod(parameters);
    • 示例:doNothing().when(myDependency).saveData(“some data”),当其他地方调用saveData方法时,什么也不做。
  • doThrow().when()

    • 用途: 用于模拟方法抛出异常。当某个方法(无论是否有返回值)被调用时,强制它抛出一个特定的异常。
    • 语法: doThrow(new ExceptionType()).when(mockObject).method(parameters);
      • 对于 void 方法,直接使用 doThrow().when()。
      • 对于有返回值的方法,也可以用 when().thenThrow()。
    • 示例1:doThrow(new IllegalArgumentException(“Invalid operation”)).when(myDependency).doSomethingThatThrowsException(),当myDependency.doSomethingThatThrowsException()被调用时,抛出IllegalArgumentException;
    • 示例2:when(myDependency.fetchData(“errorId”)).thenThrow(new RuntimeException(“Network error”)),当myDependency.fetchData(“errorId”)被调用时,抛出RuntimeException。
  • verify()

    • 用途: 用于验证模拟对象的方法是否被调用过,以及被调用的次数和传入的参数。
    • 语法: verify(mockObject, verificationMode).method(parameters);
    • 常用验证模式:
      • times(n): 被调用 n 次。
      • atLeast(n): 至少被调用 n 次。
      • atMost(n): 最多被调用 n 次。
      • never(): 从未被调用。
      • only(): 只有这个方法被调用过(且只调用一次)。
    • 示例:verify(myDependency, times(1)).fetchData(“someId”),验证myDependency.fetchData(“someId”)被调用了1次;verify(myDependency, never()).saveData(anyString()),验证myDependency.saveData()从未被调用过;verify(myDependency, times(2)).calculate(anyInt(), anyInt()),验证myDependency.calculate()被调用了2次,并且参数是任意整数。

4. 其他进阶主题

4.1 缓存支持(Cache)

  • @EnableCaching: 启用 Spring 缓存功能,通常放在配置类或启动类上。
  • @Cacheable: 标记方法,表示该方法的返回值可以被缓存。当方法被调用时,Spring 会先检查缓存,如果命中则直接返回缓存值;如果未命中,则执行方法并将结果放入缓存。
  • @CachePut: 总是执行方法,并将结果放入缓存。
  • @CacheEvict: 从缓存中移除数据。
  • @Caching: 组合多个缓存操作。
Spring 缓存支持
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// 1. 启动类或配置类上添加 @EnableCaching
// src/main/java/com/yutao/demo/DemoApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching; // 导入此注解

@SpringBootApplication
@EnableCaching // 启用缓存支持
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

// 2. 模拟一个简单的UserRepository
// src/main/java/com/yutao/demo/repository/UserRepository.java
package com.yutao.demo.repository;

import com.yutao.demo.entity.User;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Repository
public class UserRepository {
    private final Map<Long, User> users = new HashMap<>();

    public UserRepository() {
        users.put(1L, new User(1L, "Alice", "[email protected]"));
        users.put(2L, new User(2L, "Bob", "[email protected]"));
    }

    public Optional<User> findById(Long id) {
        System.out.println(">>> 正在从数据库(模拟)查找用户 ID: " + id); // 用于观察是否真正执行了查询
        try {
            Thread.sleep(500); // 模拟耗时操作
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return Optional.ofNullable(users.get(id));
    }

    public User save(User user) {
        if (user.getId() == null) {
            Long newId = (long) (users.size() + 1);
            user.setId(newId);
        }
        users.put(user.getId(), user);
        return user;
    }
}

// 3. UserService中使用@Cacheable
// src/main/java/com/yutao/demo/service/UserService.java
package com.yutao.demo.service;

import com.yutao.demo.entity.User;
import com.yutao.demo.repository.UserRepository;
import org.springframework.cache.annotation.CacheEvict; // 导入
import org.springframework.cache.annotation.Cacheable; // 导入
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Cacheable(value = "users", key = "#id") // value是缓存的名称,key是缓存的键
    public Optional<User> getUserById(Long id) {
        return userRepository.findById(id);
    }

    @CacheEvict(value = "users", key = "#user.id") // 当用户更新时,清除对应ID的缓存
    public User updateUser(Long id, User userDetails) {
        return userRepository.save(userDetails); // 模拟保存
    }

    // 其他方法省略...
    public User createUser(User user) {
         return userRepository.save(user);
    }
    public java.util.List<User> getAllUsers() {
        // Not cached for simplicity in this example
        return null; // Placeholder
    }
    public void deleteUser(Long id) {
        userRepository.findById(id).ifPresent(user -> userRepository.delete(user.getId()));
    }
}

4.2 定时任务(Scheduling)

  • @EnableScheduling: 启用 Spring 的定时任务调度器,通常放在配置类或启动类上。
  • @Scheduled: 标记方法,表示该方法是一个定时任务。可以配置触发的频率、固定延迟或 CRON 表达式。
Srping 定时任务
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 1. 启动类或配置类上添加 @EnableScheduling
// src/main/java/com/yutao/demo/DemoApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling; // 导入此注解

@SpringBootApplication
@EnableScheduling // 启用定时任务支持
@EnableCaching // 保持之前的注解,不冲突
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

// 2. 定义定时任务组件
// src/main/java/com/yutao/demo/config/MyScheduler.java (或者放在service包下,取决于你的结构偏好)
package com.yutao.demo.config;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.text.SimpleDateFormat;
import java.util.Date;

@Component // 将其声明为Spring组件,以便Spring能够扫描并管理它
public class MyScheduler {

    private static final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");

    // 固定间隔执行:每隔5秒执行一次,从上次任务完成之后开始计时
    // fixedDelayString 可以使用占位符,如 ${my.fixed.delay:5000}
    @Scheduled(fixedDelay = 5000)
    public void reportCurrentTime() {
        System.out.println("定时任务:现在时间是 " + dateFormat.format(new Date()));
    }

    // CRON 表达式:每天凌晨 2 点执行
    // 你可以使用在线 CRON 表达式生成器来帮助理解和创建
    @Scheduled(cron = "0 0 2 * * ?") // 秒 分 时 日 月 周
    public void dailyReport() {
        System.out.println("定时任务:每天凌晨 2 点执行日报生成...");
        // 这里可以放置生成报告、数据清理等业务逻辑
    }
}

4.3 Profile 与环境配置

  • @Profile: 标记 Bean 或配置类,表示只有在特定 Profile 激活时才加载。
  • Environment: Spring 提供的接口,用于访问当前应用程序运行的环境属性和 Profile 信息。
  • spring.profiles.active: 在 application.properties 或命令行中设置,用于激活一个或多个 Profile。
  • 使用案例及含义:假设你的应用程序在开发环境使用内存数据库 H2,而在生产环境使用 MySQL。

当你在启动应用时设置 spring.profiles.active=dev (例如,在 application.properties 中添加 spring.profiles.active=dev,或通过命令行参数 –spring.profiles.active=dev),那么 application-dev.properties 的配置会被加载,并且 devDatabaseInfo Bean 会被创建。prodDatabaseInfo Bean 则不会。同理,设置为 prod 时,application-prod.properties 和 prodDatabaseInfo 会被激活。Environment 接口允许你在代码中动态检查当前激活的 Profile 或获取配置属性。

Profile 与环境配置
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 1. application.properties (默认配置,可不指定特定profile)
// src/main/resources/application.properties
# 默认配置可能用于开发或者作为其他profile的基准
server.port=8080
app.message=This is the default message.

// 2. application-dev.properties (开发环境特有配置)
// src/test/resources/application-test.properties (你命名的是application-test.properties,通常用于测试环境)
# 通常用于开发环境
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
app.message=This is the DEVELOPMENT message.

// 3. application-prod.properties (生产环境特有配置)
// 假设你有一个
// src/main/resources/application-prod.properties (你需要在项目中手动创建此文件)
spring.datasource.url=jdbc:mysql://prod-db-server:3306/prod_db
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.username=prod_user
spring.datasource.password=prod_password
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
app.message=This is the PRODUCTION message.

// 4. 配置类中根据Profile创建不同的Bean
// src/main/java/com/yutao/demo/config/AppConfig.java
package com.yutao.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment; // 导入

@Configuration
public class AppConfig {

    private final Environment env; // 注入Environment

    public AppConfig(Environment env) {
        this.env = env;
    }

    // 定义一个在 "dev" 或 "test" profile 下激活的 Bean
    @Bean
    @Profile({"dev", "test"})
    public String devDatabaseInfo() {
        System.out.println("激活开发/测试数据库配置: " + env.getProperty("spring.datasource.url"));
        return "Dev/Test Database Enabled";
    }

    // 定义一个在 "prod" profile 下激活的 Bean
    @Bean
    @Profile("prod")
    public String prodDatabaseInfo() {
        System.out.println("激活生产数据库配置: " + env.getProperty("spring.datasource.url"));
        return "Production Database Enabled";
    }

    @Bean
    public String currentProfileMessage() {
        // 获取当前激活的Profile
        String[] activeProfiles = env.getActiveProfiles();
        StringBuilder message = new StringBuilder("当前激活的Profile: ");
        if (activeProfiles.length > 0) {
            for (String profile : activeProfiles) {
                message.append(profile).append(" ");
            }
        } else {
            message.append("没有特定Profile激活 (默认)");
        }
        message.append(" | 应用消息: ").append(env.getProperty("app.message"));
        System.out.println(message.toString());
        return message.toString();
    }
}

4.4. 验证(Validation)

  • @Valid: 标记需要进行验证的对象(通常是请求体或方法参数)。
  • JSR-303 标准验证注解:@NotBlank, @NotNull, @Size, @Min, @Max, @Email, @Pattern 等。
  • 自定义校验器:如果你有复杂的业务验证逻辑,可以实现 ConstraintValidator 接口创建自定义注解。

在 User 实体中使用了 @NotBlank, @Email, @Size,现在看如何在 UserController 中使用 @Valid 来触发它们,并简单处理验证失败。在 createUser 和 updateUser 方法的 @RequestBody User user 参数前添加了 @Valid。这意味着在 Spring 将 JSON 请求体绑定到 User 对象后,会立即触发对 User 对象上定义的 JSR-303 注解进行验证。如果验证失败(例如,name 为空,或 email 格式不正确),Spring 会抛出 MethodArgumentNotValidException。 @ExceptionHandler(MethodArgumentNotValidException.class) 注解的方法会捕获这个异常,并返回一个包含所有验证错误信息的 JSON 响应,状态码为 400 Bad Request。这样客户端就能清楚地知道哪些字段验证失败以及失败原因。

验证 (Validation)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 1. User实体类 (这里只做参考)
// src/main/java/com/yutao/demo/entity/User.java
// ... (imports)
public class User {
    // ...
    @NotBlank(message = "Name is mandatory")
    @Size(max = 100, message = "Name cannot exceed 100 characters")
    private String name;

    @NotBlank(message = "Email is mandatory")
    @Email(message = "Email should be valid")
    @Column(unique = true)
    private String email;
    // ... (constructors, getters, setters)
}

// 2. UserController中使用@Valid
// src/main/java/com/yutao/demo/controller/UserController.java
package com.yutao.demo.controller;

import com.yutao.demo.entity.User;
import com.yutao.demo.service.UserService;
import jakarta.validation.Valid; // 导入此注解
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; // 导入此异常
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
        User createdUser = userService.createUser(user);
        return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
    }

    @PutMapping("/{id}")
    public ResponseEntity<User> updateUser(@PathVariable Long id, @Valid @RequestBody User userDetails) {
        try {
            User updatedUser = userService.updateUser(id, userDetails);
            return new ResponseEntity<>(updatedUser, HttpStatus.OK);
        } catch (RuntimeException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    // 全局异常处理,捕获验证失败的异常
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((org.springframework.validation.FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return errors;
    }
    // ... (其他方法,如 getUserById, getAllUsers, deleteUser)
}

4.5 Remoting(RMI / HTTP Invoker)

  • Remoting 是一种允许应用程序通过网络调用远程服务的方法。它使得分布式应用中的不同部分可以像调用本地对象一样调用远程对象。
  • RMI (Remote Method Invocation):Java 原生的远程调用机制,基于 Java 对象序列化。通常用于纯 Java 环境。
  • HTTP Invoker:Spring 特有的远程调用机制,通过 HTTP 协议传输序列化的 Java 对象。它比 RMI 更适合穿透防火墙(因为使用标准 HTTP 端口),但客户端和服务端都必须是 Java 应用。

当你需要一个独立的客户端应用(可能是桌面应用或另一个微服务)直接调用另一个 Spring Boot 应用中的某个服务时,Remoting 可以在不使用 REST API 抽象的情况下提供一种“方法级别”的远程调用。然而,在现代微服务架构中,RESTful API (HTTP + JSON/XML) 已经成为更主流和推荐的跨服务通信方式,因为它具有平台无关性、更好的可伸缩性和更广泛的工具支持。因此,RMI/HTTP Invoker 在新项目中使用得越来越少,咱们作为理解原理。服务端:HttpInvokerServiceExporter 将 UserService 的实例包装起来,并把它发布为一个可以通过 HTTP 访问的服务。@Bean(name = “/remoteUserService”) 使得这个服务可以通过 http://:/remoteUserService 访问。客户端:HttpInvokerProxyFactoryBean 创建了一个 RemoteUserService 接口的代理对象。当客户端调用这个代理对象的方法时,Spring 会将方法调用(包括参数)序列化成二进制流,通过 HTTP POST 请求发送到服务端。服务端接收后反序列化,调用真实的服务方法,再将结果序列化后通过 HTTP 响应返回给客户端。客户端接收后反序列化,得到结果。

服务端:HTTP Invoker 简化示例
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
// 1. 服务接口 (服务端和客户端都需要这个接口)
// src/main/java/com/yutao/demo/service/RemoteUserService.java (可以是你现有的UserService,但通常会定义一个专门的接口)
package com.yutao.demo.service;

import com.yutao.demo.entity.User; // 确保导入 User
import java.util.Optional;

public interface RemoteUserService {
    Optional<User> findUserById(Long id);
    User createUser(User user);
    String getRemoteServiceStatus(); // 简单方法用于测试
}

// 2. 服务接口的实现 (UserService 可以实现它,或者创建一个新的实现)
// src/main/java/com/yutao/demo/service/UserService.java (假设 UserService 实现了 RemoteUserService)
package com.yutao.demo.service;

import com.yutao.demo.entity.User;
import com.yutao.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;

import java.util.Optional;
import java.util.List; // Added for getAllUsers

@Service
public class UserService implements RemoteUserService { // 实现接口

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override // 实现 RemoteUserService 接口方法
    @Cacheable(value = "users", key = "#id")
    public Optional<User> findUserById(Long id) { // 接口方法名可能与之前的不同,保持一致
        return userRepository.findById(id);
    }

    @Override // 实现 RemoteUserService 接口方法
    public User createUser(User user) {
        return userRepository.save(user);
    }

    @Override // 实现 RemoteUserService 接口方法
    public String getRemoteServiceStatus() {
        return "UserService is running at " + new java.util.Date();
    }

    // 其他原有方法,可以保留或调整以匹配你的设计
    public Optional<User> getUserById(Long id) { // 原有的名字
        return findUserById(id); // 内部调用接口方法
    }

    @CacheEvict(value = "users", key = "#userDetails.id") // 使用userDetails.id来清除缓存
    public User updateUser(Long id, User userDetails) {
        return userRepository.save(userDetails);
    }

    public List<User> getAllUsers() {
        // Not implemented for this example
        return null;
    }
    public void deleteUser(Long id) {
         userRepository.findById(id).ifPresent(user -> userRepository.delete(user.getId()));
    }
}


// 3. 服务端配置 (发布服务)
// src/main/java/com/yutao/demo/config/AppConfig.java
package com.yutao.demo.config;

import com.yutao.demo.service.RemoteUserService; // 导入远程服务接口
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.remoting.httpinvoker.HttpInvokerServiceExporter; // 导入

// ... (其他AppConfig内容)

@Configuration
public class AppConfig { // 确保已经有@Configuration

    private final RemoteUserService remoteUserService; // 注入服务的实现

    public AppConfig(RemoteUserService remoteUserService) { // 修改构造函数以注入
        this.remoteUserService = remoteUserService;
    }

    // 将 UserService 暴露为 HTTP Invoker 服务
    @Bean(name = "/remoteUserService") // 定义服务路径
    public HttpInvokerServiceExporter httpInvokerServiceExporter() {
        HttpInvokerServiceExporter exporter = new HttpInvokerServiceExporter();
        exporter.setService(remoteUserService); // 设置要暴露的服务实例
        exporter.setServiceInterface(RemoteUserService.class); // 设置服务接口
        return exporter;
    }

    // ... (其他 @Profile 的 Bean 和 currentProfileMessage 方法)
}
客户端:另一个 Spring Boot 应用或独立 Java 应用
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 1. 客户端配置 (引用远程服务)
// 假设这是客户端应用的配置类
// 需要把 User.java 和 RemoteUserService.java 两个文件也放在客户端项目中,因为它们定义了数据模型和服务接口。
package com.yutao.client.config; // 假设客户端有自己的包结构

import com.yutao.demo.entity.User; // 确保导入,数据传输需要
import com.yutao.demo.service.RemoteUserService; // 确保导入,服务接口需要
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.remoting.httpinvoker.HttpInvokerProxyFactoryBean; // 导入

@Configuration
public class ClientAppConfig {

    @Bean
    public HttpInvokerProxyFactoryBean remoteUserServiceProxy() {
        HttpInvokerProxyFactoryBean proxy = new HttpInvokerProxyFactoryBean();
        proxy.setServiceInterface(RemoteUserService.class);
        // 指向远程服务的URL,假设服务端运行在8080端口,且服务名为/remoteUserService
        proxy.setServiceUrl("http://localhost:8080/remoteUserService");
        return proxy;
    }
}

// 2. 客户端使用远程服务
// 假设在客户端的某个服务或组件中
package com.yutao.client.usage;

import com.yutao.demo.entity.User;
import com.yutao.demo.service.RemoteUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class RemoteServiceCaller implements CommandLineRunner {

    @Autowired
    private RemoteUserService remoteUserService; // 注入代理对象

    @Override
    public void run(String... args) throws Exception {
        System.out.println("--- 客户端调用远程服务 ---");

        // 调用远程方法
        String status = remoteUserService.getRemoteServiceStatus();
        System.out.println("远程服务状态: " + status);

        Optional<User> userOptional = remoteUserService.findUserById(1L);
        userOptional.ifPresent(user -> System.out.println("远程获取用户: " + user.getName() + " (" + user.getEmail() + ")"));

        User newUser = new User("Client Created", "[email protected]");
        User createdUser = remoteUserService.createUser(newUser);
        System.out.println("远程创建用户: " + createdUser.getName() + " (ID: " + createdUser.getId() + ")");
    }
}

总结

Spring 框架是 Java 生态的基石,必知必会。