JAVA30-Springboot-security与自动化测试

Springboot-security

  • Springboot-security(鉴权模块)
1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
  • Add WebSecurityConfig

默认情况下,springboot-security是拦截所有请求的
因此我们需要配置让它放行我们需要的请求路径

1
2
3
4
5
6
7
8
9
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests().antMatchers("/", "/auth/**").permitAll(); // 对根路径和所有/auth路径都放行
    }
}

鉴权

  • @Configuration中声明两个Bean,这两个Bean是鉴权的关键,一个用于鉴定权限,一个用于加密密码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests().antMatchers("/", "/auth/**").permitAll();
    }

    /* 鉴权管理器 */
    @Bean
    public AuthenticationManager customAuthenticationManager() throws Exception {
        return authenticationManager();
    }

    /* 加密器 */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • Service
 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
@Service
public class UserService implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public boolean registerUser(String username, String password) {
        return userMapper.registerUser(username, bCryptPasswordEncoder.encode(password)); // 加密存储密码
    }

    public User selectUserByUsername(String username) {
        return userMapper.selectUserByUsername(username);
    }

    /* 覆盖loadUserByUsername方法,查询对应用户是否存在,并返回用户的详细信息 */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = selectUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username + " 不存在!");
        }
        return org.springframework.security.core.userdetails.User
                .withUsername(username)
                .password(user.getPassword())
                .authorities(Collections.emptyList())
                .build();
    }
}
  • Controller
 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
@RestController
public class UserController {
    @Autowired
    private UserService userService;
    @Autowired
    private AuthenticationManager authenticationManager;

    @PostMapping("/auth/login")
    public Result login(@RequestBody Map<String, String> body) {
        String username = body.get("username");
        String password = body.get("password");
        UserDetails userDetails;
        try {
            userDetails = userService.loadUserByUsername(username); /* 获取用户详细信息 */
        } catch (UsernameNotFoundException e) {
            return Result.fail("用户不存在");
        }
        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(
                        userDetails, password, userDetails.getAuthorities()); /* 根据用户详细信息创建token(令牌)对象,它保存了用户信息 */
        try {
            authenticationManager.authenticate(token); /* 验证令牌 */
            SecurityContextHolder.getContext().setAuthentication(token); // 将信息保存到Session中
        } catch (BadCredentialsException e) {
            return Result.fail("密码错误");
        }
        return Result.ok("登录成功", userService.selectUserByUsername(username), true);
    }


}

状态保持

1
2
3
4
5
6
7
8
@GetMapping("/auth")
public Result auth() {
    /* 我把这个 Authentication 就看作 Session 对象 */
    Authentication session = SecurityContextHolder.getContext().getAuthentication();
    User user = userService.selectUserByUsername(session == null ? null : session.getName());
    if (user == null) return Result.fail("用户尚未登录!");
    return Result.ok("用户已登录", user, true);
}

注销登录(清除上下文对象)

1
2
3
4
5
6
7
@GetMapping("/auth/logout")
    /* 如果已经执行过clearContext,那么这里会是Null */
    Authentication session = SecurityContextHolder.getContext().getAuthentication();
    if (session == null) return Result.fail("用户尚未登录"); /* 因此这里需要避免一下空指针异常 */
    SecurityContextHolder.clearContext();
    return Result.ok("注销成功");
}

自动化测试与持续集成

自动化测试的好处

  • 自动化
  • 节省你的时间
  • 避免犯错(是人都会犯错)
  • 测试可以反映一个人的水平

编写单元测试

Junit 5

  • Junit 是一套测试框架
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!-- 依赖 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.8.1</version>
    <scope>test</scope>
</dependency>
    
<!-- 用于单元测试 -->
<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.22.2</version>
</plugin>
<!-- 用于集成测试 -->
<plugin>
    <artifactId>maven-failsafe-plugin</artifactId>
    <version>2.22.2</version>
</plugin>

冒烟测试

  • 什么是冒烟测试?

程序创建好后的第一次测试,主要用于测试一下程序能否正常编译

1
2
3
4
5
public class SmokeTest {
    @Test
    public void test(){
    }
}

Mock

  • 什么是Mock

我们的对象都会有一些依赖,但我们编写测试的时候不方便或无法获得那些依赖,因此就需要一些假的数据
Mock就是假的依赖的对象,例如我们的UserService依赖UserDao,那么我们可以创建一个MockDao来模拟UserDao
所谓Mock就是创建一个假的服务,这就是Mock的概念

Mockito

  • Mockito是一套Mock框架
1
2
3
4
5
6
7
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>4.1.0</version>
    <scope>test</scope>
</dependency>
使用Mockito编写单元测试
  • 例如我们要对这个Service构建单元测试
 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
@Service
public class UserService implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public boolean registerUser(String username, String password) {
        return userMapper.registerUser(username, bCryptPasswordEncoder.encode(password));
    }

    public User selectUserByUsername(String username) {
        return userMapper.selectUserByUsername(username);
    }

    /* 覆盖loadUserByUsername方法,返回对应用户的详细信息 */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = selectUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username + " 不存在!");
        }
        return org.springframework.security.core.userdetails.User
                .withUsername(username)
                .password(user.getPassword())
                .authorities(Collections.emptyList())
                .build();
    }
}
  • Test
 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
@ExtendWith(MockitoExtension.class) /* 为这个测试启用Mockito扩展 */
class UserServiceTest {
    @Mock
    BCryptPasswordEncoder mockBCryptPasswordEncoder; /* 假的BCryptPasswordEncoder */
    @Mock
    UserMapper mockUserMapper; /* 假的UserMapper */
    @InjectMocks
    UserService userService; /* 真实的UserService,并为他注入上面的Mock依赖 */

    @Test
    public void testRegisterUser() {
        /* 当mockBCryptPasswordEncoder.encode("plaintext")的时候,让它返回"ciphertext",模拟加密过程 */
        Mockito.when(mockBCryptPasswordEncoder.encode("plaintext")).thenReturn("ciphertext");
        userService.registerUser("test", "plaintext"); /* 测试这个方法 */
        /* 我希望调用registerUser的时候,传递给mockUserMapper的值是"test"和"ciphertext" */
        Mockito.verify(mockUserMapper).registerUser("test", "ciphertext"); /* 验证一下mockUserMapper以什么方式被调用了 */
    }

    @Test
    public void testSelectUserByUsername() {
        userService.selectUserByUsername("test");
        Mockito.verify(mockUserMapper).selectUserByUsername("test"); /* 验证一下mockUserMapper是否收到的值是"test" */
    }

    @Test
    public void testLoadUserByUsernameThrowsUsernameNotFoundException() {
        /* 当调用mockUserMapper.selectUserByUsername("test"),模拟让它返回null */
//        Mockito.when(mockUserMapper.selectUserByUsername("test")).thenReturn(null);
        /* 上面的实际是多余的,因为你不对它配置的时候,它默认就是返回null */

        /* 断言一下,当执行userService.loadUserByUsername("test")的时候会抛出UsernameNotFoundException异常 */
        Assertions.assertThrows(UsernameNotFoundException.class,
                () -> userService.loadUserByUsername("test"));
    }

    @Test
    public void testLoadUserByUsernameReturnUserDetail() {
        /* 当调用mockUserMapper.selectUserByUsername("test")模拟让它返回一个User对象 */
        Mockito.when(mockUserMapper.selectUserByUsername("test"))
                .thenReturn(new User("test", "ciphertext"));
        UserDetails userDetails = userService.loadUserByUsername("test"); /* 调用实际的逻辑 */
        /* 验证返回的结果是否符合预期 */
        Assertions.assertEquals("test", userDetails.getUsername());
        Assertions.assertEquals("ciphertext", userDetails.getPassword());
    }
}

为Springboot的Controller编写测试

  • 为Springboot的Controller编写测试需要引入Springboot的依赖
1
2
3
4
5
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
  • Test
 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
@ExtendWith(MockitoExtension.class)
@ExtendWith(SpringExtension.class)
class UserControllerTest {
    private MockMvc mockMvc; /* 测试Controller必须的,模拟各种请求 */

    @Mock
    private UserService userService;
    @Mock
    private AuthenticationManager authenticationManager;

    private BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();

    /* 每次调用测试方法之前,都会执行 */
    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.standaloneSetup(new UserController(userService, authenticationManager)).build();
    }

    @Test
    void testAuthByDefault() throws Exception {
        /* 执行一个Get请求,请求auth接口,并预期的返回的状态码是 200,并且响应中包含"用户尚未登录" */
        mockMvc.perform(MockMvcRequestBuilders.get("/auth"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(result -> Assertions.assertTrue(result.getResponse().getContentAsString(StandardCharsets.UTF_8).contains("用户尚未登录")));
    }

    @Test
    void testLogin() throws Exception {
        /* 发送一个Get请求,请求auth接口,并预期的返回的状态码是 200,并且响应中包含"用户尚未登录" */
        mockMvc.perform(MockMvcRequestBuilders.get("/auth"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(result -> Assertions.assertTrue(result.getResponse().getContentAsString(StandardCharsets.UTF_8).contains("用户尚未登录")));

        /* Mock一些依赖 */
        Mockito.when(userService.loadUserByUsername("admin123"))
                .thenReturn(new User("admin123", bCryptPasswordEncoder.encode("admin123"), Collections.emptyList()));
        Mockito.when(userService.selectUserByUsername("admin123"))
                .thenReturn(new com.github.wjinlei.springbootblog.entity.User("admin123", "admin123"));

        /* 准备post参数 */
        Map<String, String> params = new HashMap<>();
        params.put("username", "admin123");
        params.put("password", "admin123");
        String jsonValue = new ObjectMapper().writeValueAsString(params); /* 将Map序列化成json字符串 */
        /* 发送一个Get请求,请求/auth/login接口,并预期返回的状态码是 200,并且拿到它的返回值 */
        MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post("/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonValue))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(result -> Assertions.assertTrue(result.getResponse().getContentAsString(StandardCharsets.UTF_8).contains("登录成功")))
                .andReturn();


        HttpSession session = mvcResult.getRequest().getSession(); /* 从请求中获取session */

        /* 再次发起GET /auth 请求,并断言结果中包含"用户已登录" */
        mockMvc.perform(MockMvcRequestBuilders.get("/auth").session((MockHttpSession) session))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(result -> Assertions.assertTrue(result.getResponse().getContentAsString(StandardCharsets.UTF_8).contains("用户已登录")));
    }
}

集成测试

所谓集成测试,就是把整个项目打包,对整个项目暴露出来的外部接口进行测试,而不是对某个类,某个方法,每个单元进行测试

在Maven中如何启用集成测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- 用于单元测试 -->
<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.22.2</version>
    <configuration>
    <!-- 从单元测试中排除 IntegrationTest名称的测试 -->
        <excludes>
            <exclude>**/*IntegrationTest</exclude>
        </excludes>
    </configuration>
</plugin>
<!-- 用于集成测试 -->
<plugin>
    <artifactId>maven-failsafe-plugin</artifactId>
    <version>2.22.2</version>
    <configuration>
    <!-- 从集成测试中包含 IntegrationTest名称的测试 -->
        <includes>
            <include>**/*IntegrationTest</include>
        </includes>
    </configuration>
</plugin>

Jenkins

  • 安装Jenkins
1
sudo docker run --name jenkins -p 8081:8080 -p 50000:50000 -v `pwd`/jenkins_data:/var/jenkins_home jenkins/jenkins
  • Jenkins 创建一个工程构建项目 创建工程

在Maven中启动一个MySQL

集成测试需要启动一个真正的环境,然后在进行整体测试
例如,如果项目依赖MySQL,也就意味着,你在进行集成测试之前,应该先启动一个MySQL
要在Maven中启动MySQL,我们需要一个插件exec-maven-plugin,它可以帮助我们执行一个外部命令

  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
<plugins>
    <!-- 用于单元测试 -->
    <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.22.2</version>
        <configuration>
            <!-- 从单元测试中排除 IntegrationTest名称的测试 -->
            <excludes>
                <exclude>**/*IntegrationTest</exclude>
            </excludes>
        </configuration>
    </plugin>
    <!-- 用于集成测试 -->
    <plugin>
        <artifactId>maven-failsafe-plugin</artifactId>
        <version>2.22.2</version>
        <configuration>
            <includes>
                <include>**/*IntegrationTest</include>
            </includes>
        </configuration>
    </plugin>
    <!-- 用于执行一个外部命令 -->
    <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.0.0</version>
        <executions>
            <execution>
                <!-- 可以有多个execution, 每一个execution,需要一个id,用以区分 -->
                <id>start-test-database</id>
                <!-- 绑定到哪个阶段上,Maven在运行到这个阶段的时候,会自动帮我们运行整个goal -->
                <phase>pre-integration-test</phase>
                <goals>
                    <goal>exec</goal>
                </goals>
                <configuration>
                    <!-- 执行docker命令启动一个测试用MySQL-->
                    <executable>sudo</executable>
                    <arguments>
                        <argument>docker</argument>
                        <argument>run</argument>
                        <argument>--name</argument>
                        <argument>test-mysql</argument>
                        <argument>-p</argument>
                        <argument>3307:3306</argument>
                        <argument>-e</argument>
                        <argument>MYSQL_ROOT_PASSWORD=root</argument>
                        <argument>-e</argument>
                        <argument>MYSQL_DATABASE=blog</argument>
                        <argument>-d</argument>
                        <argument>mysql:8.0.27</argument>
                    </arguments>
                </configuration>
            </execution>
            <!-- 测试数据库启动之后,先等待20秒左右,等待数据库初始化 -->
            <execution>
                <id>wait-test-database</id>
                <phase>pre-integration-test</phase>
                <goals>
                    <goal>exec</goal>
                </goals>
                <configuration>
                    <executable>ping</executable>
                    <arguments>
                        <argument>-c</argument>
                        <argument>20</argument>
                        <argument>baidu.com</argument>
                    </arguments>
                </configuration>
            </execution>
            <!-- 集成测试之后销毁测试数据库 -->
            <execution>
                <id>rm-test-database</id>
                <phase>post-integration-test</phase>
                <goals>
                    <goal>exec</goal>
                </goals>
                <configuration>
                    <executable>sudo</executable>
                    <arguments>
                        <argument>docker</argument>
                        <argument>rm</argument>
                        <argument>-f</argument>
                        <argument>test-mysql</argument>
                    </arguments>
                </configuration>
            </execution>
        </executions>
    </plugin>
    <!-- flyway插件也绑定到pre-integration-test阶段,并且要在exec插件之后,因为要等数据启动起来后再migrate-->
    <plugin>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-maven-plugin</artifactId>
        <version>8.0.4</version>
        <configuration>
            <url>jdbc:mysql://localhost:3306/blog?characterEncoding=utf-8</url>
            <user>root</user>
            <password>root</password>
        </configuration>
        <executions>
            <execution>
                <id>initialize</id>
                <phase>pre-integration-test</phase>
                <goals>
                    <goal>migrate</goal>
                </goals>
                <!-- 这个execution额外的配置,因为是对测试数据库执行,而不是真实的数据库,因此端口指向测试数据库的端口3307 -->
                <configuration>
                    <url>jdbc:mysql://localhost:3307/blog?characterEncoding=utf-8</url>
                    <user>root</user>
                    <password>root</password>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

编写一个集成测试

 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
@ExtendWith(SpringExtension.class)
/* 指定要测试SpringbootBlogApplication, 并指定一个随机端口,因为你不能占用正常的端口 */
@SpringBootTest(classes = SpringbootBlogApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
/* 指定一个测试的配置文件,因为真实的项目配置文件连接的数据库是3306,而我们测试数据库是3307 */
/* 我们可以在test包中新建一个resources/test.properties配置文件并修改端口为3307 */
@TestPropertySource(locations = "classpath:test.properties")
public class AppIntegrationTest {
    @Inject
    Environment environment;

    /**
     * 测试首页能够访问
     */
    @Test
    void testAuthByDefault() throws IOException, InterruptedException {
        /* 获得随机启动的端口号 */
        String port = environment.getProperty("local.server.port");
        /* 使用Java 11 自带的httpclient库发送请求 */
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("http://localhost:" + port + "/auth"))
                .build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        Assertions.assertEquals(200, response.statusCode());
        Assertions.assertTrue(response.body().contains("用户尚未登录"));
    }
}
updatedupdated2025-03-012025-03-01