Java深入解析篇三十九之集成测试

集成测试详解

一、集成测试概述与测试金字塔

1.1 测试金字塔模型

测试金字塔(Test Pyramid)由Mike Cohn提出,描述了不同层级测试的比例关系:

复制代码
        /  E2E  \          ← 少量,验证完整业务流程
       /----------\
      / Integration \      ← 适量,验证组件交互
     /----------------\
    /    Unit Tests     \   ← 大量,验证单个单元
   /______________________\
测试层级 速度 数量 关注点 典型工具
单元测试 毫秒级 最多 单个类/方法逻辑 JUnit 5, Mockito
集成测试 秒级 适中 组件间交互、外部依赖 Spring Boot Test, Testcontainers
E2E测试 分钟级 最少 完整用户流程 Selenium, Cypress

1.2 集成测试的定义与目标

集成测试验证多个组件/模块协同工作时的行为,重点关注:

  • 组件交互:Service → Repository → Database 的完整链路
  • 外部依赖:数据库、消息队列、缓存、第三方API
  • 配置正确性:Spring上下文装配、Bean依赖注入
  • 数据一致性:事务边界、并发场景下的数据完整性

1.3 集成测试 vs 单元测试

java 复制代码
// 单元测试:隔离测试Service逻辑,Mock掉Repository
@ExtendWith(MockitoExtension.class)
class OrderServiceUnitTest {
    @Mock
    private OrderRepository orderRepository;
    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldCalculateTotalPrice() {
        when(orderRepository.findById(1L))
            .thenReturn(Optional.of(new Order(List.of(
                new OrderItem("iPhone", 2, 5999.0)
            ))));
        double total = orderService.calculateTotal(1L);
        assertEquals(11998.0, total);
    }
}

// 集成测试:验证Service + Repository + Database的完整链路
@SpringBootTest
class OrderServiceIntegrationTest {
    @Autowired
    private OrderService orderService;
    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistAndRetrieveOrder() {
        Order order = orderService.createOrder(new CreateOrderRequest("iPhone", 2));
        Order found = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals("iPhone", found.getItems().get(0).getProductName());
        assertEquals(2, found.getItems().get(0).getQuantity());
    }
}

二、Spring Boot Test注解体系

2.1 @SpringBootTest --- 完整应用上下文

@SpringBootTest 加载完整的Spring应用上下文,是最"重量级"的测试注解:

java 复制代码
@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = {
        "spring.datasource.url=jdbc:tc:mysql:8.0:///testdb",
        "app.feature.new-checkout=true"
    }
)
@ActiveProfiles("test")
class FullApplicationIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ApplicationContext context;

    @Test
    void contextLoads() {
        assertNotNull(context.getBean(OrderService.class));
    }

    @Test
    void shouldCreateOrderViaApi() {
        var request = new CreateOrderRequest("MacBook Pro", 1);
        ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
            "/api/orders", request, OrderResponse.class);

        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody().getId());
    }
}

webEnvironment 选项:

选项 说明 适用场景
MOCK(默认) 模拟Servlet环境,不启动真实服务器 配合MockMvc使用
RANDOM_PORT 启动真实服务器,随机端口 配合TestRestTemplate/WebTestClient
DEFINED_PORT 启动真实服务器,使用配置端口 需要固定端口的场景
NONE 不创建Web环境 非Web应用/消息消费者测试

2.2 @WebMvcTest --- Web层切片测试

仅加载Controller层相关Bean,Service层需要Mock:

java 复制代码
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderList() throws Exception {
        List<OrderResponse> orders = List.of(
            new OrderResponse(1L, "iPhone", 5999.0),
            new OrderResponse(2L, "iPad", 3999.0)
        );
        when(orderService.getAllOrders()).thenReturn(orders);

        mockMvc.perform(get("/api/orders")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(2)))
            .andExpect(jsonPath("$[0].productName").value("iPhone"))
            .andExpect(jsonPath("$[1].price").value(3999.0));

        verify(orderService).getAllOrders();
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        when(orderService.getOrderById(999L))
            .thenThrow(new ResourceNotFoundException("Order not found"));

        mockMvc.perform(get("/api/orders/999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.message").value("Order not found"));
    }

    @Test
    void shouldValidateRequestBody() throws Exception {
        String invalidJson = """
            {"productName": "", "quantity": -1}
            """;

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidJson))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors.productName").exists());
    }
}

2.3 @DataJpaTest --- 持久层切片测试

仅加载JPA相关组件(Entity、Repository),默认使用内嵌H2数据库:

java 复制代码
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindByNameContaining() {
        entityManager.persist(new Product("iPhone 15", 5999.0, "ELECTRONICS"));
        entityManager.persist(new Product("iPhone 14", 4999.0, "ELECTRONICS"));
        entityManager.persist(new Product("Galaxy S24", 4599.0, "ELECTRONICS"));
        entityManager.flush();

        List<Product> results = productRepository.findByNameContaining("iPhone");

        assertEquals(2, results.size());
        assertTrue(results.stream().allMatch(p -> p.getName().contains("iPhone")));
    }

    @Test
    void shouldSupportPagination() {
        for (int i = 0; i < 20; i++) {
            entityManager.persist(new Product("Product-" + i, 100.0 * i, "GENERAL"));
        }
        entityManager.flush();

        Page<Product> page = productRepository.findAll(
            PageRequest.of(0, 5, Sort.by("price").descending()));

        assertEquals(20, page.getTotalElements());
        assertEquals(4, page.getTotalPages());
        assertEquals(5, page.getContent().size());
    }

    @Test
    void shouldExecuteCustomQuery() {
        entityManager.persist(new Product("Laptop", 8999.0, "ELECTRONICS"));
        entityManager.persist(new Product("Phone", 3999.0, "ELECTRONICS"));
        entityManager.flush();

        List<Product> expensive = productRepository.findByPriceGreaterThan(5000.0);

        assertEquals(1, expensive.size());
        assertEquals("Laptop", expensive.get(0).getName());
    }
}

2.4 @JsonTest --- JSON序列化测试

java 复制代码
@JsonTest
class OrderJsonSerializationTest {

    @Autowired
    private JacksonTester<OrderResponse> json;

    @Test
    void shouldSerializeOrder() throws IOException {
        OrderResponse order = new OrderResponse(1L, "iPhone", 5999.0);

        assertThat(json.write(order))
            .hasJsonPathNumberValue("@.id")
            .hasJsonPathStringValue("@.productName")
            .extractingJsonPathStringValue("@.productName")
            .isEqualTo("iPhone");
    }

    @Test
    void shouldDeserializeOrder() throws IOException {
        String jsonContent = """
            {"id": 1, "productName": "MacBook", "price": 12999.0}
            """;

        assertThat(json.parse(jsonContent))
            .usingRecursiveComparison()
            .isEqualTo(new OrderResponse(1L, "MacBook", 12999.0));
    }
}

三、MockMvc(API测试)

3.1 MockMvc配置方式

java 复制代码
// 方式一:@WebMvcTest 自动配置(推荐)
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
    @Autowired
    private MockMvc mockMvc;
}

// 方式二:@SpringBootTest + @AutoConfigureMockMvc
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerFullTest {
    @Autowired
    private MockMvc mockMvc;
}

// 方式三:手动构建(Standalone,不加载Spring上下文)
class OrderControllerStandaloneTest {
    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        OrderService orderService = mock(OrderService.class);
        OrderController controller = new OrderController(orderService);
        mockMvc = MockMvcBuilders.standaloneSetup(controller)
            .setControllerAdvice(new GlobalExceptionHandler())
            .setMessageConverters(new MappingJackson2HttpMessageConverter())
            .build();
    }
}

3.2 完整CRUD测试示例

java 复制代码
@WebMvcTest(ProductController.class)
@Import(SecurityConfig.class)
class ProductControllerCrudTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void createProduct_shouldReturn201() throws Exception {
        CreateProductRequest request = new CreateProductRequest("AirPods Pro", 1899.0);
        ProductResponse response = new ProductResponse(1L, "AirPods Pro", 1899.0);
        when(productService.create(any())).thenReturn(response);

        mockMvc.perform(post("/api/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
                .header("Authorization", "Bearer valid-token"))
            .andDo(print())
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/products/1"))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("AirPods Pro"));
    }

    @Test
    void updateProduct_shouldReturn200() throws Exception {
        UpdateProductRequest request = new UpdateProductRequest("AirPods Max", 4399.0);
        ProductResponse response = new ProductResponse(1L, "AirPods Max", 4399.0);
        when(productService.update(eq(1L), any())).thenReturn(response);

        mockMvc.perform(put("/api/products/1")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("AirPods Max"))
            .andExpect(jsonPath("$.price").value(4399.0));
    }

    @Test
    void deleteProduct_shouldReturn204() throws Exception {
        doNothing().when(productService).delete(1L);

        mockMvc.perform(delete("/api/products/1"))
            .andExpect(status().isNoContent());

        verify(productService).delete(1L);
    }

    @Test
    void listProducts_withPagination() throws Exception {
        Page<ProductResponse> page = new PageImpl<>(
            List.of(new ProductResponse(1L, "iPhone", 5999.0)),
            PageRequest.of(0, 10), 1);
        when(productService.list(any())).thenReturn(page);

        mockMvc.perform(get("/api/products")
                .param("page", "0")
                .param("size", "10")
                .param("sort", "price,desc"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content", hasSize(1)))
            .andExpect(jsonPath("$.totalElements").value(1));
    }
}

3.3 文件上传测试

java 复制代码
@Test
void shouldUploadFile() throws Exception {
    MockMultipartFile file = new MockMultipartFile(
        "file", "report.pdf",
        MediaType.APPLICATION_PDF_VALUE,
        "PDF content".getBytes());

    when(fileService.upload(any())).thenReturn(new FileUploadResponse("file-123", "report.pdf"));

    mockMvc.perform(multipart("/api/files/upload")
            .file(file)
            .param("category", "reports"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.fileId").value("file-123"));
}

四、WebTestClient(响应式API测试)

4.1 基本用法

java 复制代码
@WebFluxTest(ReactiveOrderController.class)
class ReactiveOrderControllerTest {

    @Autowired
    private WebTestClient webTestClient;

    @MockBean
    private ReactiveOrderService orderService;

    @Test
    void shouldReturnOrderFlux() {
        when(orderService.getAllOrders()).thenReturn(Flux.just(
            new OrderResponse(1L, "iPhone", 5999.0),
            new OrderResponse(2L, "iPad", 3999.0)
        ));

        webTestClient.get()
            .uri("/api/reactive/orders")
            .accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .expectBodyList(OrderResponse.class)
            .hasSize(2)
            .contains(new OrderResponse(1L, "iPhone", 5999.0));
    }

    @Test
    void shouldCreateOrder() {
        CreateOrderRequest request = new CreateOrderRequest("MacBook", 1);
        when(orderService.createOrder(any()))
            .thenReturn(Mono.just(new OrderResponse(1L, "MacBook", 12999.0)));

        webTestClient.post()
            .uri("/api/reactive/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(request)
            .exchange()
            .expectStatus().isCreated()
            .expectBody()
            .jsonPath("$.id").isEqualTo(1)
            .jsonPath("$.productName").isEqualTo("MacBook");
    }
}

4.2 绑定到真实服务器

java 复制代码
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReactiveApiIntegrationTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void shouldStreamServerSentEvents() {
        webTestClient.get()
            .uri("/api/events/stream")
            .accept(MediaType.TEXT_EVENT_STREAM)
            .exchange()
            .expectStatus().isOk()
            .returnResult(String.class)
            .getResponseBody()
            .take(5)  // 取前5个事件
            .as(StepVerifier::create)
            .expectNextCount(5)
            .verifyComplete();
    }

    @Test
    void shouldHandleTimeout() {
        webTestClient = webTestClient.mutate()
            .responseTimeout(Duration.ofSeconds(10))
            .build();

        webTestClient.get()
            .uri("/api/slow-endpoint")
            .exchange()
            .expectStatus().isOk();
    }
}

五、Testcontainers(Docker容器化测试)

5.1 核心概念与依赖

Testcontainers 是一个Java库,利用Docker在测试期间启动真实容器,测试结束后自动销毁:

xml 复制代码
<!-- pom.xml -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>

5.2 基本使用模式

java 复制代码
@Testcontainers
class BasicContainerTest {

    // 每个测试方法启动新容器
    @Container
    private GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @Test
    void shouldConnectToRedis() {
        String host = redis.getHost();
        Integer port = redis.getMappedPort(6379);

        Jedis jedis = new Jedis(host, port);
        jedis.set("key", "value");
        assertEquals("value", jedis.get("key"));
    }
}

5.3 @ServiceConnection(Spring Boot 3.1+)

Spring Boot 3.1引入了 @ServiceConnection,自动配置容器连接属性:

java 复制代码
@SpringBootTest
@Testcontainers
class ServiceConnectionTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldConnectToPostgres() {
        jdbcTemplate.execute("CREATE TABLE test_table (id INT, name VARCHAR(50))");
        jdbcTemplate.update("INSERT INTO test_table VALUES (1, 'hello')");

        String name = jdbcTemplate.queryForObject(
            "SELECT name FROM test_table WHERE id = 1", String.class);
        assertEquals("hello", name);
    }
}

5.4 容器复用(加速测试)

java 复制代码
// 方式一:withReuse(true) + ~/.testcontainers.properties 中设置 testcontainers.reuse.enable=true
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
    .withDatabaseName("testdb")
    .withUsername("test")
    .withPassword("test")
    .withReuse(true);

// 方式二:Singleton Container Pattern(推荐)
public abstract class AbstractIntegrationTest {

    static final MySQLContainer<?> MYSQL;

    static {
        MYSQL = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
        MYSQL.start();
    }

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
        registry.add("spring.datasource.username", MYSQL::getUsername);
        registry.add("spring.datasource.password", MYSQL::getPassword);
    }
}

六、Testcontainers + MySQL/PostgreSQL

6.1 MySQL集成测试

java 复制代码
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class MySqlIntegrationTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("order_db")
        .withUsername("root")
        .withPassword("secret")
        .withInitScript("schema-mysql.sql")
        .withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci");

    @DynamicPropertySource
    static void mysqlProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldUseMySqlSpecificFunctions() {
        // 测试MySQL特有函数,H2无法支持
        orderRepository.save(new Order("ORD-001", LocalDateTime.now()));

        List<Object[]> results = orderRepository.findOrdersWithDateFormat();
        assertFalse(results.isEmpty());
    }

    @Test
    void shouldHandleJsonColumn() {
        // MySQL 8.0 JSON类型支持
        Order order = new Order();
        order.setMetadata("{\"priority\": \"high\", \"source\": \"web\"}");
        orderRepository.save(order);

        Order found = orderRepository.findById(order.getId()).orElseThrow();
        assertTrue(found.getMetadata().contains("\"priority\": \"high\""));
    }
}

6.2 PostgreSQL集成测试

java 复制代码
@SpringBootTest
@Testcontainers
class PostgresIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("app_db")
        .withUsername("postgres")
        .withPassword("postgres")
        .withInitScript("init-postgres.sql");

    @DynamicPropertySource
    static void postgresProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldSupportPostgresArrayTypes() {
        jdbcTemplate.update(
            "INSERT INTO articles (title, tags) VALUES (?, ?)",
            "Test Article", new String[]{"java", "spring", "testing"});

        String[] tags = jdbcTemplate.queryForObject(
            "SELECT tags FROM articles WHERE title = ?",
            (rs, rowNum) -> (String[]) rs.getArray("tags").getArray(),
            "Test Article");

        assertArrayEquals(new String[]{"java", "spring", "testing"}, tags);
    }

    @Test
    void shouldSupportFullTextSearch() {
        jdbcTemplate.update("INSERT INTO documents (content) VALUES (?)",
            "Spring Boot makes integration testing easy with Testcontainers");

        Integer count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM documents WHERE to_tsvector(content) @@ to_tsquery('spring & testing')",
            Integer.class);

        assertEquals(1, count);
    }
}

七、Testcontainers + Redis/Kafka

7.1 Redis容器测试

java 复制代码
@SpringBootTest
@Testcontainers
class RedisIntegrationTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379)
        .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));

    @DynamicPropertySource
    static void redisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private CacheService cacheService;

    @Test
    void shouldCacheAndRetrieve() {
        cacheService.cacheProduct(1L, new Product("iPhone", 5999.0));

        Product cached = cacheService.getCachedProduct(1L);
        assertNotNull(cached);
        assertEquals("iPhone", cached.getName());
    }

    @Test
    void shouldExpireCache() throws InterruptedException {
        redisTemplate.opsForValue().set("temp-key", "temp-value", 1, TimeUnit.SECONDS);

        assertEquals("temp-value", redisTemplate.opsForValue().get("temp-key"));
        Thread.sleep(1500);
        assertNull(redisTemplate.opsForValue().get("temp-key"));
    }

    @Test
    void shouldSupportRedisPubSub() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<String> received = new AtomicReference<>();

        redisTemplate.execute((RedisCallback<Void>) connection -> {
            connection.subscribe((message, pattern) -> {
                received.set(new String(message.getBody()));
                latch.countDown();
            }, "notifications".getBytes());
            return null;
        });

        redisTemplate.convertAndSend("notifications", "order-created");
        assertTrue(latch.await(5, TimeUnit.SECONDS));
        assertEquals("order-created", received.get());
    }
}

7.2 Kafka容器测试

java 复制代码
@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
        .withKraft();  // 使用KRaft模式,无需Zookeeper

    @DynamicPropertySource
    static void kafkaProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
        registry.add("spring.kafka.consumer.auto-offset-reset", () -> "earliest");
        registry.add("spring.kafka.consumer.group-id", () -> "test-group");
    }

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Autowired
    private OrderEventPublisher eventPublisher;

    @Test
    void shouldProduceAndConsumeMessage() throws Exception {
        // 使用KafkaConsumer直接验证
        Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(
            "test-verify", "true", kafka.getBootstrapServers());
        Consumer<String, String> consumer = new DefaultKafkaConsumer<>(consumerProps);
        consumer.subscribe(List.of("order-events"));

        kafkaTemplate.send("order-events", "order-1", "{\"status\":\"CREATED\"}").get();

        ConsumerRecords<String, String> records = KafkaTestUtils.getRecords(consumer, Duration.ofSeconds(10));
        assertThat(records.count()).isGreaterThan(0);
        consumer.close();
    }

    @Test
    void shouldProcessOrderEventEndToEnd() throws Exception {
        // 测试完整的 生产者 → Kafka → 消费者 链路
        CountDownLatch latch = new CountDownLatch(1);
        // 假设有一个 @KafkaListener 将处理结果写入数据库

        eventPublisher.publishOrderCreated(new OrderCreatedEvent("ORD-100", 5999.0));

        // 等待消费者处理完成
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        // 验证消费者副作用(如数据库记录)
    }
}

八、数据库测试(@DataJpaTest/H2/Testcontainers)

8.1 H2内嵌数据库测试

java 复制代码
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
class H2RepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindByEmailIgnoreCase() {
        entityManager.persist(new User("Alice", "alice@example.com"));
        entityManager.flush();

        Optional<User> user = userRepository.findByEmailIgnoreCase("ALICE@EXAMPLE.COM");
        assertTrue(user.isPresent());
    }
}

H2的局限性:

  • 不支持MySQL的 JSON_EXTRACTON DUPLICATE KEY UPDATE
  • 不支持PostgreSQL的数组类型、ON CONFLICT
  • 存储过程支持有限
  • 某些窗口函数行为不同

8.2 使用Testcontainers替代H2

java 复制代码
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class RealDatabaseRepositoryTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb");

    @DynamicPropertySource
    static void dbProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
    }

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldExecuteNativeQueryWithMySqlSyntax() {
        productRepository.saveAll(List.of(
            new Product("A", 100.0), new Product("B", 200.0), new Product("C", 300.0)));

        // MySQL特有的 GROUP_CONCAT
        String names = productRepository.getAllProductNamesConcatenated();
        assertTrue(names.contains("A"));
        assertTrue(names.contains("B"));
    }
}

8.3 TestEntityManager的使用

java 复制代码
@DataJpaTest
class EntityManagerTest {

    @Autowired
    private TestEntityManager em;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldManageEntityLifecycle() {
        // persistAndFlush = persist + flush,立即写入数据库
        Order order = em.persistAndFlush(new Order("ORD-001", OrderStatus.PENDING));

        // 验证ID已生成
        assertNotNull(order.getId());

        // 修改并验证
        order.setStatus(OrderStatus.CONFIRMED);
        em.flush();

        Order found = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals(OrderStatus.CONFIRMED, found.getStatus());

        // 删除
        em.remove(found);
        em.flush();
        assertTrue(orderRepository.findById(order.getId()).isEmpty());
    }
}

九、测试数据管理(@Sql/Flyway)

9.1 @Sql注解

java 复制代码
@SpringBootTest
@Sql(scripts = {
    "/sql/clean-tables.sql",
    "/sql/insert-test-data.sql"
}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/clean-tables.sql",
     executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class SqlDataManagementTest {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldQueryPreloadedData() {
        Integer count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM products", Integer.class);
        assertEquals(10, count);  // insert-test-data.sql 插入了10条
    }
}
sql 复制代码
-- src/test/resources/sql/clean-tables.sql
DELETE FROM order_items;
DELETE FROM orders;
DELETE FROM products;

-- src/test/resources/sql/insert-test-data.sql
INSERT INTO products (id, name, price, category) VALUES
(1, 'iPhone 15', 5999.00, 'ELECTRONICS'),
(2, 'MacBook Pro', 12999.00, 'ELECTRONICS'),
(3, 'AirPods Pro', 1899.00, 'ACCESSORIES');

9.2 Flyway迁移测试

java 复制代码
@SpringBootTest(properties = {
    "spring.flyway.enabled=true",
    "spring.flyway.locations=classpath:db/migration"
})
@Testcontainers
class FlywayMigrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private Flyway flyway;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldApplyAllMigrations() {
        MigrationInfo[] applied = flyway.info().applied();
        assertTrue(applied.length >= 3);

        // 验证最终schema状态
        Integer columnCount = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'orders'",
            Integer.class);
        assertTrue(columnCount > 0);
    }

    @Test
    void shouldMigrateFromSpecificVersion() {
        // 测试增量迁移
        flyway.clean();
        flyway.migrate();

        // 验证数据完整性约束
        assertThrows(DataIntegrityViolationException.class, () ->
            jdbcTemplate.update("INSERT INTO orders (id, customer_id) VALUES (1, 99999)"));
    }
}

9.3 测试数据工厂(ObjectMother模式)

java 复制代码
public class TestDataFactory {

    private static final AtomicLong ID_SEQ = new AtomicLong(1000);

    public static Product aProduct() {
        return Product.builder()
            .id(ID_SEQ.incrementAndGet())
            .name("Test Product")
            .price(99.99)
            .category("GENERAL")
            .stock(100)
            .createdAt(LocalDateTime.now())
            .build();
    }

    public static Product aProductWithPrice(double price) {
        return aProduct().toBuilder().price(price).build();
    }

    public static Order anOrderWithItems(int itemCount) {
        Order order = Order.builder()
            .id(ID_SEQ.incrementAndGet())
            .orderNo("ORD-" + UUID.randomUUID().toString().substring(0, 8))
            .status(OrderStatus.PENDING)
            .build();
        for (int i = 0; i < itemCount; i++) {
            order.addItem(new OrderItem(aProduct(), i + 1));
        }
        return order;
    }
}

// 使用
@Test
void shouldCalculateDiscount() {
    Order order = TestDataFactory.anOrderWithItems(3);
    double total = pricingService.calculateWithDiscount(order, 0.9);
    // assertions...
}

十、契约测试(Spring Cloud Contract)

10.1 契约定义(Groovy DSL)

groovy 复制代码
// src/test/resources/contracts/order/shouldReturnOrder.groovy
org.springframework.cloud.contract.spec.Contract.make {
    description "should return order by id"

    request {
        method GET()
        url "/api/orders/1"
        headers {
            contentType(applicationJson())
        }
    }

    response {
        status OK()
        body([
            id: 1,
            orderNo: "ORD-2024-001",
            status: "CONFIRMED",
            items: [[
                productName: "iPhone 15",
                quantity: 2,
                unitPrice: 5999.0
            ]],
            totalAmount: 11998.0
        ])
        headers {
            contentType(applicationJson())
        }
    }
}

10.2 生产者端验证

xml 复制代码
<!-- 生产者 pom.xml -->
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>4.1.2</version>
    <extensions>true</extensions>
    <configuration>
        <baseClassForTests>com.example.contract.BaseContractTest</baseClassForTests>
        <build>
            <testFramework>JUNIT5</testFramework>
        </build>
    </configuration>
</plugin>
java 复制代码
// 生产者端:契约测试基类
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
public abstract class BaseContractTest {

    @Autowired
    private WebApplicationContext context;

    @MockBean
    private OrderService orderService;

    @BeforeEach
    void setup() {
        RestAssuredMockMvc.webAppContextSetup(context);

        // 设置通用Mock行为
        when(orderService.getOrderById(1L)).thenReturn(new OrderResponse(
            1L, "ORD-2024-001", "CONFIRMED",
            List.of(new OrderItemResponse("iPhone 15", 2, 5999.0)),
            11998.0
        ));
    }
}

10.3 消费者端使用Stub

java 复制代码
// 消费者端测试
@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.example:order-service:+:stubs:8080",
    stubRunnerProperties = {"spring.cloud.contract.stubrunner.repository.root=file://target/stubs"}
)
class OrderClientContractTest {

    @Autowired
    private OrderClient orderClient;  // 消费者的Feign/RestTemplate客户端

    @Test
    void shouldGetOrderFromProvider() {
        OrderResponse order = orderClient.getOrder(1L);

        assertNotNull(order);
        assertEquals("ORD-2024-001", order.getOrderNo());
        assertEquals("CONFIRMED", order.getStatus());
        assertEquals(11998.0, order.getTotalAmount());
    }
}

十一、WireMock(外部服务Mock)

11.1 基本配置与使用

java 复制代码
@SpringBootTest(properties = "payment.service.url=http://localhost:${wiremock.server.port}")
@WireMockTest(httpPort = 0)  // 随机端口
class PaymentServiceIntegrationTest {

    @Autowired
    private PaymentGatewayClient paymentClient;

    @Test
    void shouldProcessPaymentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
        // 定义Stub
        stubFor(post(urlEqualTo("/api/v1/payments"))
            .withRequestBody(matchingJsonPath("$.amount", equalTo("5999.0")))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "transactionId": "TXN-12345",
                        "status": "SUCCESS",
                        "message": "Payment processed"
                    }
                    """)));

        // 执行
        PaymentResult result = paymentClient.processPayment(new PaymentRequest("ORD-001", 5999.0));

        // 验证
        assertEquals("SUCCESS", result.getStatus());
        assertEquals("TXN-12345", result.getTransactionId());

        // 验证请求确实发出
        verify(postRequestedFor(urlEqualTo("/api/v1/payments"))
            .withHeader("Content-Type", equalTo("application/json")));
    }

    @Test
    void shouldHandlePaymentTimeout(WireMockRuntimeInfo wmRuntimeInfo) {
        stubFor(post(urlEqualTo("/api/v1/payments"))
            .willReturn(aResponse()
                .withStatus(200)
                .withFixedDelay(5000)));  // 模拟5秒延迟

        assertThrows(PaymentTimeoutException.class, () ->
            paymentClient.processPayment(new PaymentRequest("ORD-002", 100.0)));
    }

    @Test
    void shouldRetryOnServerError(WireMockRuntimeInfo wmRuntimeInfo) {
        // 第一次返回500,第二次返回200
        stubFor(post(urlEqualTo("/api/v1/payments"))
            .inScenario("retry")
            .whenScenarioStateIs(Scenario.STARTED)
            .willReturn(aResponse().withStatus(500))
            .willSetStateTo("SECOND_CALL"));

        stubFor(post(urlEqualTo("/api/v1/payments"))
            .inScenario("retry")
            .whenScenarioStateIs("SECOND_CALL")
            .willReturn(aResponse()
                .withStatus(200)
                .withBody("{\"transactionId\":\"TXN-RETRY\",\"status\":\"SUCCESS\"}")));

        PaymentResult result = paymentClient.processPayment(new PaymentRequest("ORD-003", 200.0));
        assertEquals("SUCCESS", result.getStatus());

        // 验证重试:共调用了2次
        verify(2, postRequestedFor(urlEqualTo("/api/v1/payments")));
    }
}

11.2 录制与回放

java 复制代码
@Test
void recordExternalApiCalls() {
    // 启动录制模式(通常手动执行一次,保存响应)
    WireMock.configureFor("localhost", 8080);
    WireMock.startRecording(
        recordSpec()
            .forTarget("https://api.external-service.com")
            .captureHeader("Authorization")
            .extractTextBodiesOver(10)
    );

    // 执行真实调用...
    externalClient.fetchData();

    // 停止录制,Stub自动保存
    SnapshotRecordResult result = WireMock.stopRecording();
    // 保存的Stub可用于后续测试
}

十二、测试切片策略

12.1 切片选择指南

复制代码
需要测试什么?
├── Controller层(请求映射、参数校验、响应格式)
│   └── @WebMvcTest / @WebFluxTest
├── Service + Repository + DB(业务逻辑+持久化)
│   └── @SpringBootTest + Testcontainers
├── 仅Repository(SQL正确性)
│   └── @DataJpaTest
├── JSON序列化/反序列化
│   └── @JsonTest
├── REST客户端调用
│   └── @RestClientTest
└── 消息消费者
    └── @SpringBootTest + Testcontainers(Kafka/RabbitMQ)

12.2 自定义测试切片

java 复制代码
// 定义切片注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@BootstrapWith(KafkaSliceTestContextBootstrapper.class)
@AutoConfigureKafkaTest
public @interface KafkaSliceTest {
}

// 自动配置类
@AutoConfiguration
@ConditionalOnClass(KafkaTemplate.class)
public class KafkaTestAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public KafkaTestUtils kafkaTestUtils(ConsumerFactory<String, String> consumerFactory) {
        return new KafkaTestUtils(consumerFactory);
    }
}

// 注册到 META-INF/spring/org.springframework.boot.test.autoconfigure.core.AutoConfigureImportFilter
// 或 META-INF/spring.factories

// 使用
@KafkaSliceTest
class OrderEventConsumerTest {
    @Autowired
    private KafkaTestUtils kafkaTestUtils;

    @Test
    void shouldConsumeOrderEvent() {
        kafkaTestUtils.send("order-events", new OrderCreatedEvent("ORD-1", 100.0));
        // 验证消费结果...
    }
}

12.3 上下文缓存优化

Spring Test框架会缓存ApplicationContext,相同配置的测试类共享上下文:

java 复制代码
// 这两个测试类共享同一个上下文(配置完全相同)
@SpringBootTest(properties = "app.mode=test")
class ServiceATest { }

@SpringBootTest(properties = "app.mode=test")
class ServiceBTest { }

// 这个测试类会创建新上下文(配置不同)
@SpringBootTest(properties = "app.mode=production")
class ServiceCTest { }

注意事项:

  • @DirtiesContext 会强制销毁并重建上下文,慎用
  • @MockBean 会改变上下文配置,导致缓存失效
  • 尽量统一测试配置,最大化上下文复用

十三、CI/CD中的集成测试

13.1 Maven Failsafe Plugin配置

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <!-- 集成测试命名约定:*IT.java, *IntegrationTest.java -->
        <includes>
            <include>**/*IT.java</include>
            <include>**/*IntegrationTest.java</include>
        </includes>
        <!-- 并行执行 -->
        <parallel>classes</parallel>
        <threadCount>4</threadCount>
        <!-- 失败后继续执行其他测试 -->
        <testFailureIgnore>false</testFailureIgnore>
        <!-- 环境变量 -->
        <environmentVariables>
            <TESTCONTAINERS_RYUK_DISABLED>true</TESTCONTAINERS_RYUK_DISABLED>
        </environmentVariables>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

13.2 GitHub Actions配置

yaml 复制代码
# .github/workflows/integration-tests.yml
name: Integration Tests

on:
  pull_request:
    branches: [main, develop]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'
      - name: Run Unit Tests
        run: mvn test -pl '!integration-tests' --batch-mode

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    services:
      docker:
        image: docker:dind
        options: --privileged
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'
      - name: Run Integration Tests
        run: mvn verify -Pintegration-test --batch-mode
        env:
          TESTCONTAINERS_RYUK_DISABLED: "true"
      - name: Upload Test Reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: integration-test-reports
          path: target/failsafe-reports/

13.3 GitLab CI配置

yaml 复制代码
# .gitlab-ci.yml
stages:
  - test
  - integration-test

unit-test:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn test --batch-mode
  cache:
    paths:
      - .m2/repository

integration-test:
  stage: integration-test
  image: maven:3.9-eclipse-temurin-21
  services:
    - docker:24-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    TESTCONTAINERS_RYUK_DISABLED: "true"
    TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock
  script:
    - mvn verify -Pintegration-test --batch-mode
  artifacts:
    when: always
    reports:
      junit: target/failsafe-reports/*.xml

13.4 测试执行优化

java 复制代码
// 使用JUnit 5并行执行
// junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=2

十四、最佳实践

14.1 测试命名规范

java 复制代码
// 推荐:should_期望行为_when_条件
@Test
void shouldReturn404_whenOrderDoesNotExist() { }

@Test
void shouldRollbackTransaction_whenPaymentFails() { }

@Test
void shouldRetryThreeTimes_whenExternalServiceUnavailable() { }

// 使用 @DisplayName 提供更可读的描述
@Test
@DisplayName("当库存不足时,下单应返回409冲突")
void shouldReturnConflict_whenStockInsufficient() { }

14.2 测试隔离性

java 复制代码
@SpringBootTest
@Testcontainers
class IsolatedOrderTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void cleanDatabase() {
        orderRepository.deleteAll();  // 每个测试前清理
    }

    // 避免测试间依赖:每个测试独立准备数据
    @Test
    void testA() {
        orderRepository.save(new Order("ORD-A"));
        assertEquals(1, orderRepository.count());
    }

    @Test
    void testB() {
        // 不依赖testA的数据
        orderRepository.save(new Order("ORD-B"));
        assertEquals(1, orderRepository.count());  // 仍然是1,因为BeforeEach清理了
    }
}

14.3 异步测试处理

java 复制代码
@SpringBootTest
class AsyncIntegrationTest {

    @Autowired
    private AsyncNotificationService notificationService;

    @Test
    void shouldSendNotificationAsynchronously() {
        notificationService.sendAsync("user@example.com", "Order Confirmed");

        // 使用Awaitility等待异步完成
        await()
            .atMost(10, TimeUnit.SECONDS)
            .pollInterval(500, TimeUnit.MILLISECONDS)
            .untilAsserted(() -> {
                NotificationRecord record = notificationRepository
                    .findByEmail("user@example.com");
                assertNotNull(record);
                assertEquals("SENT", record.getStatus());
            });
    }
}

14.4 避免常见陷阱

java 复制代码
// 陷阱1:过度使用 @DirtiesContext(极慢)
// 错误做法
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BadTest { }

// 正确做法:使用事务回滚或手动清理
@SpringBootTest
@Transactional  // 每个测试方法后自动回滚
class GoodTest { }

// 陷阱2:测试中硬编码端口
// 错误做法
String url = "http://localhost:8080/api/orders";

// 正确做法
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ApiTest {
    @LocalServerPort
    private int port;
    // 或使用 TestRestTemplate(自动配置了正确端口)
    @Autowired
    private TestRestTemplate restTemplate;
}

// 陷阱3:依赖外部网络/服务
// 错误做法:测试中调用真实第三方API
@Test
void badTest() {
    String result = restTemplate.getForObject("https://api.real-service.com/data", String.class);
}

// 正确做法:使用WireMock或Testcontainers
@Test
void goodTest() {
    stubFor(get("/data").willReturn(aResponse().withBody("{\"key\":\"value\"}")));
    String result = restTemplate.getForObject(wireMockUrl + "/data", String.class);
}

14.5 测试配置Profile

yaml 复制代码
# src/test/resources/application-test.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 5
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  flyway:
    enabled: true
  kafka:
    consumer:
      auto-offset-reset: earliest

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.testcontainers: INFO
    com.github.dockerjava: WARN

app:
  feature:
    new-checkout: true
  external:
    payment-timeout: 5s

14.6 性能优化清单

优化手段 效果 实现方式
容器复用 减少容器启动时间 Singleton Pattern / withReuse(true)
上下文缓存 避免重复加载Spring上下文 统一测试配置,减少@MockBean
并行执行 缩短总执行时间 JUnit 5 parallel + Maven parallel
分层执行 快速反馈 单元测试先行,集成测试后行
镜像预拉取 避免CI中重复下载 CI缓存层 / 私有Registry
轻量镜像 减少容器启动时间 使用alpine版本(postgres:16-alpine)
禁用Ryuk 减少额外容器开销 TESTCONTAINERS_RYUK_DISABLED=true

14.7 集成测试检查清单

  • 测试是否覆盖了核心业务链路(下单→支付→发货)
  • 数据库测试是否使用了与生产一致的数据库引擎
  • 外部服务是否通过WireMock/契约测试隔离
  • 测试数据是否独立,无测试间依赖
  • 异步操作是否有超时等待机制
  • CI中是否有Docker环境支持
  • 测试执行时间是否在可接受范围内(<10分钟)
  • 失败测试是否有清晰的错误信息
  • 是否覆盖了异常路径(超时、重试、降级)
  • 敏感配置是否通过环境变量/Profile隔离
相关推荐
skr爱码士1 小时前
06_Qt 常用数据类型与容器:QString、QList、QVector、QMap 的性能与实现分析
开发语言·c++·qt
总有刁民想爱朕ha1 小时前
零基础Python开发「图片批量转MP4视频」工具,本地离线、免费无水印
开发语言·python·音视频
l1t1 小时前
测试DuckDB luajit插件读取本地通达信文件
开发语言·数据库·duckdb
老一岁2 小时前
c问题总结(2)
c语言·开发语言
Ming_studying2 小时前
Python批量压缩图片:支持JPG_PNG_WebP、尺寸限制与CSV报告
开发语言·图像处理·python·pillow·图片压缩
风月说与山鬼2 小时前
JS闭包详解
开发语言·javascript
何以解忧,唯有..2 小时前
Python协程详解:从生成器到async/await的完整指南
开发语言·python
奶茶树2 小时前
【C++】13. C++11新特性【上】
c语言·开发语言·c++·git·github
SomeB1oody2 小时前
【RustyML入门】7.4. 按需裁剪与模块化集成
开发语言·后端·机器学习·rust·教程