0

OTP Spring Boot: test SMS với WireMock, Testcontainers và smoke test bằng số thật

Mình làm việc cùng HeroSMS, dịch vụ số ảo dùng ở phần smoke test. Bài viết có dùng công cụ AI; code đã được đối chiếu với tài liệu API HeroSMS và docs Spring Boot 4.1.

Test luồng OTP Spring Boot thường dừng ở @MockitoBean SmsSender: assert vài dòng, PR xanh là merge. Rồi sau một lần release, user báo không nhận được mã. Thường là ai đó sửa template SMS, key của SMS gateway hết hạn, hoặc application.properties đọc nhầm tên biến môi trường. Mock không bắt được mấy lỗi này, vì khi dùng mock thì SMS chưa bao giờ rời khỏi JVM.

Bài này dựng hai tầng test. Tầng một chạy ở mỗi PR: MockMvcTester với Redis thật trong Testcontainers để test logic, WireMock để test client gọi SMS gateway. Tầng hai chạy theo lịch trên GitHub Actions: staging gửi OTP thật tới một số điện thoại thuê qua API, rồi verify bằng chính mã đó. Cách chia này giống bài test OTP SMS trong Laravel, lần này tới lượt Java.

Môi trường: Spring Boot 4.1.2, Java 25, JUnit 6, Testcontainers 2. Boot 4 đổi nhiều thứ ở tầng test, nên code viết cho Boot 3 thường không compile được. Code trong bài mới được đối chiếu với docs, Javadoc và POM, chưa chạy trên dự án thật, nên bạn hãy chạy lại trong CI của mình trước khi dùng.

OTP Spring Boot: nên test ở tầng nào

Mỗi cách test chứng minh một chuyện khác nhau. Dòng cuối của bảng là tầng hai, các dòng còn lại là tầng một:

Cách Chứng minh được gì Chi phí Khi nào chạy
MockMvcTester + @MockitoBean + Redis thật Sinh mã, verify, hết hạn, rate limit Miễn phí, cần Docker Mỗi PR
WireMock Client gọi đúng URL, header, body qua socket thật; xử lý 500, mất kết nối và timeout Miễn phí Mỗi PR
@RestClientTest + MockRestServiceServer URL, header, body, nhưng không qua socket Miễn phí Thay WireMock nếu muốn gọn
Số thật SMS tới một số thật qua gateway và nhà mạng, staging chấp nhận đúng mã đó Trả tiền theo số Theo lịch, trước release

Đừng thêm cờ kiểu SKIP_OTP=true vào backend: CI xanh nhưng khâu SMS chẳng được kiểm tra, và chỉ cần lỡ bật trên production là ai cũng đăng nhập được mà không cần mã.

Luồng gửi OTP SMS mẫu: controller, Redis và SmsSender

Để có cái cho test chạy, đây là một luồng gửi OTP SMS trong Spring Boot ở mức tối giản. Tạo project từ Initializr (Maven, Java 25, Boot 4.1.2) rồi thay phần dependencies. Từ Boot 4, spring-boot-starter-web đổi thành spring-boot-starter-webmvc. WireMock không nằm trong BOM của Boot nên phải khai báo version.

<!-- pom.xml: parent là spring-boot-starter-parent 4.1.2, java.version là 25 -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-restclient</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>

  <!-- Kéo theo spring-boot-starter-test: JUnit, AssertJ, Mockito, Awaitility -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.wiremock.integrations</groupId>
    <artifactId>wiremock-spring-boot-standalone</artifactId>
    <version>4.4.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>
# src/main/resources/application.properties
sms.gateway.url=${SMS_GATEWAY_URL}
sms.gateway.key=${SMS_GATEWAY_KEY}
otp.ttl=5m
otp.max-sends-per-minute=3
spring.http.clients.connect-timeout=2s
spring.http.clients.read-timeout=5s
// src/main/java/com/example/otp/OtpController.java
package com.example.otp;

import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

@RestController
@RequestMapping("/api/otp")
public class OtpController {

    public record SendRequest(@NotBlank @Pattern(regexp = "^\\+\\d{8,15}$") String phone) {}

    public record VerifyRequest(@NotBlank String phone,
            @NotBlank @Pattern(regexp = "^\\d{6}$") String code) {}

    private final OtpService otp;

    public OtpController(OtpService otp) {
        this.otp = otp;
    }

    @PostMapping("/send")
    public ResponseEntity<Map<String, Boolean>> send(@Valid @RequestBody SendRequest request) {
        if (!otp.send(request.phone())) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(Map.of("sent", false));
        }
        return ResponseEntity.ok(Map.of("sent", true));
    }

    @PostMapping("/verify")
    public ResponseEntity<Map<String, Boolean>> verify(@Valid @RequestBody VerifyRequest request) {
        if (!otp.verify(request.phone(), request.code())) {
            // UNPROCESSABLE_ENTITY đã deprecated từ Spring Framework 7
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_CONTENT).body(Map.of("verified", false));
        }
        return ResponseEntity.ok(Map.of("verified", true)); // app thật: tạo token hoặc session ở đây
    }
}
// src/main/java/com/example/otp/OtpService.java
package com.example.otp;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.HexFormat;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class OtpService {

    private final StringRedisTemplate redis;
    private final SmsSender smsSender;
    private final Duration ttl;
    private final int maxSendsPerMinute;
    private final SecureRandom random = new SecureRandom();

    public OtpService(StringRedisTemplate redis, SmsSender smsSender,
            @Value("${otp.ttl}") Duration ttl,
            @Value("${otp.max-sends-per-minute}") int maxSendsPerMinute) {
        this.redis = redis;
        this.smsSender = smsSender;
        this.ttl = ttl;
        this.maxSendsPerMinute = maxSendsPerMinute;
    }

    /** Trả false khi số này đã vượt giới hạn gửi trong một phút. */
    public boolean send(String phone) {
        String sendsKey = "otp:sends:" + phone;
        // Tạo key kèm TTL trước rồi mới tăng. Hai lệnh không nguyên tử: key hết hạn đúng giữa chừng
        // thì INCR tạo key không TTL; production nên gộp bằng Lua script
        redis.opsForValue().setIfAbsent(sendsKey, "0", Duration.ofMinutes(1));
        Long sends = redis.opsForValue().increment(sendsKey);
        if (sends == null || sends > maxSendsPerMinute) {
            return false;
        }

        String code = "%06d".formatted(random.nextInt(1_000_000));
        redis.opsForValue().set("otp:code:" + phone, hash(phone, code), ttl); // chỉ lưu hash
        // Không dấu: SMS có dấu dùng mã hóa Unicode, mỗi tin chứa ít ký tự hơn
        smsSender.send(phone, "Ma xac thuc cua ban la " + code);
        return true;
    }

    public boolean verify(String phone, String code) {
        String key = "otp:code:" + phone;
        String stored = redis.opsForValue().get(key);
        if (stored == null || !MessageDigest.isEqual(
                stored.getBytes(StandardCharsets.UTF_8),
                hash(phone, code).getBytes(StandardCharsets.UTF_8))) {
            return false;
        }
        // Hai request cùng mã chạy song song: chỉ request xóa được key mới thắng
        return Boolean.TRUE.equals(redis.delete(key));
    }

    private static String hash(String phone, String code) {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256")
                    .digest((phone + ":" + code).getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}
// src/main/java/com/example/otp/SmsSender.java
package com.example.otp;

public interface SmsSender {
    void send(String phone, String text);
}
// src/main/java/com/example/otp/HttpSmsSender.java
package com.example.otp;

import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
class HttpSmsSender implements SmsSender {

    private final RestClient client;

    HttpSmsSender(RestClient.Builder builder,
            @Value("${sms.gateway.url}") String url,
            @Value("${sms.gateway.key}") String key) {
        // Gửi Content-Length thay vì chunked, không phải gateway nào cũng nhận chunked
        this.client = builder.baseUrl(url)
                .bufferContent((uri, method) -> true)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key)
                .build();
    }

    @Override
    public void send(String phone, String text) {
        // 4xx, 5xx và lỗi I/O đều ném exception, không bị nuốt im lặng
        client.post().uri("/messages")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("to", phone, "text", text))
                .retrieve()
                .toBodilessEntity();
    }
}
// src/main/java/com/example/otp/HttpClientConfig.java
package com.example.otp;

import java.net.http.HttpClient;

import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class HttpClientConfig {

    // Production và test dùng chung một client: JDK HttpClient, HTTP/1.1
    @Bean
    ClientHttpRequestFactoryBuilder<?> clientHttpRequestFactoryBuilder() {
        return ClientHttpRequestFactoryBuilder.jdk()
                .withHttpClientCustomizer(builder -> builder.version(HttpClient.Version.HTTP_1_1));
    }
}

Vài chỗ đáng nói:

  • verify không dùng getAndDelete, vì khi đó mã sai cũng làm mất OTP và bước thử mã sai trong smoke test sẽ xóa luôn mã đúng. Code đọc bằng get, so sánh hash, rồi chỉ chấp nhận khi delete trả true, nên mỗi mã chỉ dùng được một lần.
  • HttpSmsSender nhận RestClient.Builder từ context để áp dụng timeout; RestClient.create() thì không đi qua auto-configuration.
  • Endpoint /messages và body to, text thuộc về một gateway giả định, bạn thay bằng API đang dùng. Verify cũng nên có limit riêng để chống dò mã, mình lược đi cho gọn.

Test luồng OTP với MockMvcTester, @MockitoBean và Redis trong Testcontainers

Với Testcontainers trên Spring Boot 4, nên khai báo container dạng bean: vòng đời của nó đi theo context, nên không gặp cảnh @Container trên field static dừng container trong khi context còn nằm trong cache.

// src/test/java/com/example/otp/RedisTestConfig.java
package com.example.otp;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.GenericContainer;

@TestConfiguration(proxyBeanMethods = false)
class RedisTestConfig {

    // Với GenericContainer, Boot không suy ra được image nên phải ghi name
    @Bean
    @ServiceConnection(name = "redis")
    GenericContainer<?> redis() {
        return new GenericContainer<>("redis:8-alpine").withExposedPorts(6379);
    }
}

@SpringBootTest của Boot 4 không còn tự cung cấp MockMvc, phải thêm @AutoConfigureMockMvc. SmsSender bị mock nên không gửi gì ra ngoài, còn Redis là thật, vì TTL và bộ đếm chính là thứ cần kiểm tra. Mấu chốt là dùng ArgumentCaptor lấy mã từ tin SMS mà mock nhận được, rồi verify bằng chính mã đó.

// src/test/java/com/example/otp/OtpTestSupport.java
package com.example.otp;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;

import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;

abstract class OtpTestSupport {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    SmsSender smsSender;

    MvcTestResult send(String phone) {
        return mvc.post().uri("/api/otp/send")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"phone\":\"%s\"}".formatted(phone))
                .exchange();
    }

    MvcTestResult verifyCode(String phone, String code) {
        return mvc.post().uri("/api/otp/verify")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"phone\":\"%s\",\"code\":\"%s\"}".formatted(phone, code))
                .exchange();
    }

    String sendAndCaptureCode(String phone) {
        assertThat(send(phone)).hasStatusOk();
        ArgumentCaptor<String> text = ArgumentCaptor.forClass(String.class);
        verify(smsSender).send(eq(phone), text.capture());
        return text.getValue().replaceAll("\\D", "");
    }
}
// src/test/java/com/example/otp/OtpFlowTest.java
package com.example.otp;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;

@SpringBootTest
@AutoConfigureMockMvc
@Import(RedisTestConfig.class)
class OtpFlowTest extends OtpTestSupport {

    // Mỗi test một số riêng để bộ đếm rate limit trong Redis không bị lẫn

    @Test
    void acceptsCorrectCode() {
        String phone = "+84900000001";
        String code = sendAndCaptureCode(phone);
        assertThat(code).matches("\\d{6}");

        assertThat(verifyCode(phone, code)).hasStatusOk()
                .bodyJson().extractingPath("$.verified").isEqualTo(true);
    }

    @Test
    void wrongCodeReturns422AndKeepsRealCode() {
        String phone = "+84900000002";
        String code = sendAndCaptureCode(phone);
        String wrong = code.equals("000000") ? "111111" : "000000";

        assertThat(verifyCode(phone, wrong)).hasStatus(HttpStatus.UNPROCESSABLE_CONTENT);
        assertThat(verifyCode(phone, code)).hasStatusOk();
    }

    @Test
    void codeWorksOnlyOnce() {
        String phone = "+84900000003";
        String code = sendAndCaptureCode(phone);

        assertThat(verifyCode(phone, code)).hasStatusOk();
        assertThat(verifyCode(phone, code)).hasStatus(HttpStatus.UNPROCESSABLE_CONTENT);
    }

    @Test
    void blocksFourthSendWithinOneMinute() {
        String phone = "+84900000004";
        for (int i = 0; i < 3; i++) {
            assertThat(send(phone)).hasStatusOk();
        }

        assertThat(send(phone)).hasStatus(HttpStatus.TOO_MANY_REQUESTS);
        verify(smsSender, times(3)).send(eq(phone), anyString());
    }

    @Test
    void rejectsMalformedPhone() {
        assertThat(send("0901234567")).hasStatus(HttpStatus.BAD_REQUEST);
    }
}

Test OTP hết hạn nằm ở class riêng. TTL của Redis chạy theo đồng hồ server nên Clock giả không có tác dụng; ở đây mình đặt otp.ttl=1s rồi dùng Awaitility.

// src/test/java/com/example/otp/OtpExpiryTest.java
package com.example.otp;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;

@SpringBootTest(properties = "otp.ttl=1s")
@AutoConfigureMockMvc
@Import(RedisTestConfig.class)
class OtpExpiryTest extends OtpTestSupport {

    @Autowired
    StringRedisTemplate redis;

    @Test
    void rejectsExpiredCode() {
        String phone = "+84900000005";
        String code = sendAndCaptureCode(phone);

        // Chờ key biến mất rồi mới verify: verify sớm thì mã đúng bị dùng mất và test xanh ảo
        await().atMost(Duration.ofSeconds(5))
                .untilAsserted(() -> assertThat(redis.hasKey("otp:code:" + phone)).isFalse());

        assertThat(verifyCode(phone, code)).hasStatus(HttpStatus.UNPROCESSABLE_CONTENT);
    }
}

Test Spring Boot với WireMock: kiểm tra client gọi SMS gateway

WireMock là một HTTP server giả chạy trên port thật, nên request đi qua đúng client HTTP, socket và timeout như production. baseUrlProperties đẩy URL của WireMock vào sms.gateway.url. Class này không import RedisTestConfig vì Lettuce chỉ kết nối khi có lệnh đầu tiên.

// src/test/java/com/example/otp/HttpSmsSenderTest.java
package com.example.otp;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.containing;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.exactly;
import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.serverError;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.client.WireMock.verify;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.wiremock.spring.ConfigureWireMock;
import org.wiremock.spring.EnableWireMock;

import com.github.tomakehurst.wiremock.http.Fault;

@SpringBootTest(properties = {
        "sms.gateway.key=test-key",
        "spring.http.clients.read-timeout=1s"
})
@EnableWireMock(@ConfigureWireMock(baseUrlProperties = "sms.gateway.url"))
class HttpSmsSenderTest {

    private static final String PHONE = "+84901234567";
    private static final String TEXT = "Ma xac thuc cua ban la 123456";

    @Autowired
    SmsSender sender;

    @Test
    void callsGatewayWithCorrectUrlHeaderAndBody() {
        stubFor(post(urlEqualTo("/messages")).willReturn(aResponse().withStatus(201)));

        sender.send(PHONE, TEXT);

        verify(exactly(1), postRequestedFor(urlEqualTo("/messages"))
                .withHeader("Authorization", equalTo("Bearer test-key"))
                .withHeader("Content-Type", containing("application/json"))
                .withRequestBody(matchingJsonPath("$.to", equalTo(PHONE)))
                .withRequestBody(matchingJsonPath("$.text", containing("123456"))));
    }

    @Test
    void throwsWhenGatewayReturns500() {
        stubFor(post(urlEqualTo("/messages")).willReturn(serverError()));

        assertThatThrownBy(() -> sender.send(PHONE, TEXT))
                .isInstanceOf(HttpServerErrorException.class);
    }

    @Test
    void throwsWhenGatewayClosesConnection() {
        // Docs WireMock lưu ý CONNECTION_RESET_BY_PEER có thể treo trên Windows
        stubFor(post(urlEqualTo("/messages"))
                .willReturn(aResponse().withFault(Fault.EMPTY_RESPONSE)));

        assertThatThrownBy(() -> sender.send(PHONE, TEXT))
                .isInstanceOf(ResourceAccessException.class);
    }

    @Test
    void throwsWhenGatewayIsTooSlow() {
        stubFor(post(urlEqualTo("/messages"))
                .willReturn(aResponse().withStatus(201).withFixedDelay(3000)));

        assertThatThrownBy(() -> sender.send(PHONE, TEXT))
                .isInstanceOf(ResourceAccessException.class);
    }
}

Bẫy: test chạy client HTTP khác với production và EOFException

Boot tự chọn client, ưu tiên Apache HttpClient rồi tới Jetty, JDK HttpClient xếp sau. Bản wiremock-spring-boot không phải standalone kéo theo httpclient5 và jetty-client, nên test có thể chạy client khác với production. Đây là mình suy luận từ POM, chưa kiểm chứng thực tế, nhưng bản standalone cộng với bean JDK ở trên loại bỏ luôn khả năng này.

Lý do ép HTTP/1.1: issue #2637 của WireMock có báo cáo java.io.EOFException: EOF reached while reading khi RestClient trên JDK HttpClient gọi WireMock qua HTTP thường, liên quan tới nâng cấp h2c. Báo cáo gốc nói về PUT, POST chưa chắc dính, nhưng ép HTTP/1.1 là hết lo. Nếu gateway cần HTTP/2, giữ nguyên client và bật http2PlainDisabled(true) qua configurationCustomizers của @ConfigureWireMock.

Smoke test với số thật qua API thuê số

Tầng hai cần một số điện thoại thật nhận được SMS từ staging. Ví dụ trong bài dùng HeroSMS: thuê một số (gọi là activation) cho một dịch vụ ở một quốc gia, poll mã qua REST, xong thì đóng hoặc hủy. Dùng nhà cung cấp khác thì viết lại HeroSmsClient. Endpoint, mã lỗi và webhook có đủ trong tài liệu API của HeroSMS. Những điểm ảnh hưởng tới code:

  • Mọi request gửi kèm header Authorization: ApiKey <token>. Lỗi trả về JSON có title và details, vài lỗi có thêm info.
  • POST /activations nhận service, country (số nguyên), amount: 1, verificationType: 'sms' và maxPrice (tùy chọn). data[0] trong phản hồi có id, phone (số nguyên, không có +), countryPhoneCode và expiredAt. Activation sống khoảng 20 phút.
  • GET /activations/{activationId}/otp/last trả data.smsCode và data.smsText, cả hai có thể null. Tài liệu không mô tả phản hồi lúc SMS chưa về, nên mình coi 400, 404 hoặc data rỗng là "chưa có", trừ khi title là ACTIVATION_NOT_ACTIVE, CANCELED, FINISHED hoặc REFUNDED. Vì vậy truyền sai id thì 404 trông giống hệt timeout.
  • POST /activations/{activationId}/finish đóng activation, không hoàn tiền. DELETE /activations/{activationId} hủy và hoàn tiền, nhưng có thể bị từ chối với OTP_RECEIVED (SMS đã về) hoặc EARLY_CANCEL_DENIED (những phút đầu, kèm info.minActivationTime).
  • Mã dịch vụ và ID quốc gia: gọi GET /activations/offers/sms không kèm filter, mã dịch vụ là key của data, ID quốc gia là key bên trong từng dịch vụ.

HeroSmsClient bằng RestClient.exchange

// src/test/java/com/example/otp/HeroSmsClient.java
package com.example.otp;

import java.math.BigDecimal;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

public class HeroSmsClient {

    public static final String API = "https://hero-sms.com/api/v1";

    private static final Set<String> CLOSED =
            Set.of("ACTIVATION_NOT_ACTIVE", "CANCELED", "FINISHED", "REFUNDED");
    private static final Pattern CODE = Pattern.compile("\\b\\d{4,8}\\b");
    private static final JsonMapper JSON = JsonMapper.shared();

    @FunctionalInterface
    public interface Sleeper {
        void sleep(Duration duration) throws InterruptedException;
    }

    public record Activation(String id, String phone, Instant boughtAt) {}

    private record Reply(int status, JsonNode body, String retryAfter) {
        boolean ok() {
            return status < 400;
        }

        String title() {
            return body.path("title").asString();
        }

        String describe() {
            return "HTTP " + status + " " + title() + " " + body.path("details").asString();
        }
    }

    private final RestClient client;
    private final Sleeper sleeper;

    public HeroSmsClient(String baseUrl, String apiKey, Sleeper sleeper) {
        this.client = jdkClient().baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "ApiKey " + apiKey)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
        this.sleeper = sleeper;
    }

    // Ngoài Spring context không có auto-configuration, nên tự đặt phiên bản HTTP và timeout
    public static RestClient.Builder jdkClient() {
        HttpClient http = HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_1_1)
                .connectTimeout(Duration.ofSeconds(5))
                .build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(http);
        factory.setReadTimeout(Duration.ofSeconds(15));
        // Gửi Content-Length thay vì chunked, không phải gateway nào cũng nhận chunked
        return RestClient.builder().requestFactory(factory).bufferContent((uri, method) -> true);
    }

    public Activation buy(String service, int country, BigDecimal maxPrice) {
        Map<String, Object> body = new HashMap<>(Map.of(
                "service", service, "country", country, "amount", 1, "verificationType", "sms"));
        if (maxPrice != null) {
            body.put("maxPrice", maxPrice);
        }

        // Không retry: request mua bị timeout vẫn có thể đã tạo activation
        Reply reply = call(client.post().uri("/activations")
                .contentType(MediaType.APPLICATION_JSON)
                .body(body));
        JsonNode first = reply.body().path("data").path(0);
        if (!reply.ok() || first.isMissingNode()) {
            throw new IllegalStateException("POST /activations thất bại: " + reply.describe());
        }
        return new Activation(first.path("id").asString(),
                "+" + first.path("phone").asString(), Instant.now());
    }

    public String waitForCode(String id, Duration timeout) throws InterruptedException {
        Instant deadline = Instant.now().plus(timeout);
        long delayMs = 2_000;

        while (Instant.now().isBefore(deadline)) {
            long waitMs = delayMs;
            try {
                Reply reply = call(client.get().uri("/activations/{id}/otp/last", id));
                if (reply.ok()) {
                    String code = extractCode(reply.body().path("data"));
                    if (code != null) {
                        return code;
                    }
                } else if (reply.status() == 429) {
                    waitMs = retryAfterMillis(reply.retryAfter());
                } else if ((reply.status() != 400 && reply.status() != 404 && reply.status() < 500)
                        || CLOSED.contains(reply.title())) {
                    // 5xx không vào nhánh này: lỗi tạm thời, poll tiếp cho tới deadline
                    throw new IllegalStateException("Dừng poll: " + reply.describe());
                }
            } catch (RestClientException e) {
                // Mạng chập chờn: coi như chưa có và poll tiếp, không đốt mất số đã trả tiền
            }

            long leftMs = Duration.between(Instant.now(), deadline).toMillis();
            sleeper.sleep(Duration.ofMillis(Math.max(0, Math.min(waitMs, leftMs))));
            delayMs = Math.min(delayMs * 2, 10_000);
        }
        throw new IllegalStateException("Không có SMS cho activation " + id + " sau " + timeout);
    }

    // Không bao giờ ném lỗi: chạy trong finally, lỗi dọn dẹp không được che lỗi thật của test
    public void release(Activation activation, boolean gotSms) {
        String id = activation.id();
        try {
            if (gotSms) {
                logIfFailed(id, finish(id));
                return;
            }

            Reply reply = cancel(id); // không có SMS: hủy để hoàn tiền
            if ("EARLY_CANCEL_DENIED".equals(reply.title())) {
                long minSeconds = reply.body().path("info").path("minActivationTime").asLong(120);
                Instant readyAt = activation.boughtAt().plusSeconds(minSeconds + 5);
                long waitMs = Duration.between(Instant.now(), readyAt).toMillis();
                sleeper.sleep(Duration.ofMillis(Math.max(0, waitMs)));
                reply = cancel(id); // thử lại đúng một lần
            }
            if ("OTP_RECEIVED".equals(reply.title())) {
                reply = finish(id); // SMS về muộn: đóng thay vì hủy
            }
            logIfFailed(id, reply);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (RuntimeException e) {
            System.err.println("[herosms] release " + id + ": " + e.getMessage());
        }
    }

    private Reply finish(String id) {
        return call(client.post().uri("/activations/{id}/finish", id));
    }

    private Reply cancel(String id) {
        return call(client.delete().uri("/activations/{id}", id));
    }

    // exchange không áp dụng status handler: 404 và 429 trả về như phản hồi bình thường, không ném exception
    private Reply call(RestClient.RequestHeadersSpec<?> spec) {
        return spec.exchange((request, response) -> new Reply(
                response.getStatusCode().value(),
                parse(response.bodyTo(String.class)),
                response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)));
    }

    private static JsonNode parse(String text) {
        if (text == null || text.isBlank()) {
            return JSON.createObjectNode();
        }
        try {
            return JSON.readTree(text);
        } catch (JacksonException e) {
            return JSON.createObjectNode(); // body của 404 hoặc 502 có thể không phải JSON
        }
    }

    private static String extractCode(JsonNode sms) {
        String code = sms.path("smsCode").asString();
        if (!code.isEmpty()) {
            return code;
        }
        String text = sms.path("smsText").asString();
        if (text.isEmpty()) {
            return null; // chưa có SMS
        }
        Matcher matcher = CODE.matcher(text); // smsCode rỗng: lấy số 4-8 chữ số đầu tiên trong tin
        if (matcher.find()) {
            return matcher.group();
        }
        throw new IllegalStateException("SMS đã về nhưng không có mã 4-8 chữ số, kiểm tra template");
    }

    private static long retryAfterMillis(String header) {
        if (header != null && header.trim().matches("\\d+")) {
            return Long.parseLong(header.trim()) * 1000;
        }
        return 5_000; // tài liệu chỉ ghi Retry-After của 429 cho endpoint offers
    }

    private static void logIfFailed(String id, Reply reply) {
        if (!reply.ok()) {
            System.err.println("[herosms] release " + id + ": " + reply.describe());
        }
    }
}

Vài chỗ đáng nói:

  • RestClient mặc định ném exception với 4xx và 5xx. exchange không áp dụng status handler, nên 404 lúc "chưa có SMS" không làm vỡ vòng poll.
  • Jackson 3 (package tools.jackson): path() trả missing node thay vì null, asString() trả chuỗi rỗng với null hoặc missing, nên không cần kiểm tra null từng tầng.
  • Lỗi I/O bị bọc thành RestClientException (đứt kết nối khi đọc body) hoặc lớp con ResourceAccessException; vòng poll bắt lớp cha, coi như "chưa có" và poll tiếp.

Test vòng poll bằng WireMock scenario, không tốn tiền

Class này không cần Spring context: WireMockServer là đủ, base URL truyền qua constructor, Sleeper chỉ ghi lại thời gian chờ. Scenario trả lần lượt 404, 429, data rỗng, rồi mới tới mã.

// src/test/java/com/example/otp/HeroSmsClientTest.java
package com.example.otp;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.delete;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.exactly;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
import static com.github.tomakehurst.wiremock.client.WireMock.notFound;
import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
import com.github.tomakehurst.wiremock.stubbing.Scenario;

class HeroSmsClientTest {

    private static final String OTP_URL = "/activations/42/otp/last";
    private static final WireMockServer wm = new WireMockServer(wireMockConfig().dynamicPort());

    private final List<Duration> slept = new ArrayList<>();
    private HeroSmsClient client;

    @BeforeAll
    static void start() {
        wm.start();
    }

    @AfterAll
    static void stop() {
        wm.stop();
    }

    @BeforeEach
    void setUp() {
        wm.resetAll();
        client = new HeroSmsClient(wm.baseUrl(), "test-key", slept::add);
    }

    private void pollStep(String from, String to, ResponseDefinitionBuilder response) {
        wm.stubFor(get(urlEqualTo(OTP_URL))
                .inScenario("poll").whenScenarioStateIs(from).willSetStateTo(to)
                .willReturn(response));
    }

    @Test
    void buyAddsPlusSignAndSendsExpectedBody() {
        wm.stubFor(post(urlEqualTo("/activations")).willReturn(okJson("""
                {"data":[{"id":42,"phone":84901234567}]}""")));

        HeroSmsClient.Activation number = client.buy("ab", 10, null);

        assertThat(number.id()).isEqualTo("42");
        assertThat(number.phone()).isEqualTo("+84901234567");
        wm.verify(exactly(1), postRequestedFor(urlEqualTo("/activations"))
                .withRequestBody(matchingJsonPath("$.service", equalTo("ab")))
                .withRequestBody(matchingJsonPath("$.country", equalTo("10")))
                .withRequestBody(matchingJsonPath("$.verificationType", equalTo("sms"))));
    }

    @Test
    void pollsThrough404And429AndEmptyDataThenReturnsCode() throws Exception {
        pollStep(Scenario.STARTED, "rate-limited", aResponse().withStatus(404));
        pollStep("rate-limited", "empty", aResponse().withStatus(429).withHeader("Retry-After", "3"));
        pollStep("empty", "delivered", okJson("""
                {"data":{"smsCode":null,"smsText":null}}"""));
        pollStep("delivered", "done", okJson("""
                {"data":{"smsCode":"482913","smsText":"Ma xac thuc cua ban la 482913"}}"""));

        assertThat(client.waitForCode("42", Duration.ofSeconds(60))).isEqualTo("482913");

        wm.verify(exactly(4), getRequestedFor(urlEqualTo(OTP_URL))
                .withHeader("Authorization", equalTo("ApiKey test-key")));
        assertThat(slept).hasSize(3).contains(Duration.ofSeconds(3));
    }

    @Test
    void failsWhenDeadlinePassesWithoutSms() {
        wm.stubFor(get(urlEqualTo(OTP_URL)).willReturn(notFound()));

        assertThatThrownBy(() -> client.waitForCode("42", Duration.ofMillis(300)))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("Không có SMS");
    }

    @Test
    void stopsPollingWhenActivationIsClosed() {
        // Status 400 là giả định: tài liệu không ghi status, code chỉ đọc title
        wm.stubFor(get(urlEqualTo(OTP_URL)).willReturn(aResponse().withStatus(400).withBody("""
                {"title":"CANCELED"}""")));

        assertThatThrownBy(() -> client.waitForCode("42", Duration.ofSeconds(60)))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("CANCELED");
    }

    @Test
    void finishesInsteadOfCancelingWhenOtpAlreadyReceived() {
        wm.stubFor(delete(urlEqualTo("/activations/42")).willReturn(aResponse().withStatus(400).withBody("""
                {"title":"OTP_RECEIVED"}""")));
        wm.stubFor(post(urlEqualTo("/activations/42/finish")).willReturn(okJson("{}")));

        client.release(new HeroSmsClient.Activation("42", "+84901234567", Instant.now()), false);

        wm.verify(exactly(1), postRequestedFor(urlEqualTo("/activations/42/finish")));
    }
}

Smoke test JUnit với @Tag("real-sms")

// src/test/java/com/example/otp/OtpRealSmsTest.java
package com.example.otp;

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

import java.math.BigDecimal;
import java.time.Duration;
import java.util.Map;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;

@Tag("real-sms")
@EnabledIfEnvironmentVariable(named = "HEROSMS_API_KEY", matches = ".+")
class OtpRealSmsTest {

    @Test
    void stagingSendsRealSmsAndAcceptsTheCode() throws Exception {
        // Đọc và kiểm tra cấu hình trước khi mua: sai định dạng thì nổ ở đây, chưa mất tiền
        String service = requireEnv("OTP_SERVICE");
        int country = Integer.parseInt(requireEnv("OTP_COUNTRY"));
        RestClient staging = HeroSmsClient.jdkClient().baseUrl(requireEnv("STAGING_URL")).build();
        String cap = System.getenv("HEROSMS_MAX_PRICE");
        BigDecimal maxPrice = cap == null || cap.isBlank() ? null : new BigDecimal(cap);

        HeroSmsClient sms = new HeroSmsClient(
                HeroSmsClient.API, System.getenv("HEROSMS_API_KEY"), Thread::sleep);
        HeroSmsClient.Activation number = sms.buy(service, country, maxPrice);
        boolean gotSms = false;

        try {
            assertThat(post(staging, "/api/otp/send", Map.of("phone", number.phone()))).isEqualTo(200);

            String code = sms.waitForCode(number.id(), Duration.ofSeconds(120));
            gotSms = true;
            String wrong = code.equals("000000") ? "111111" : "000000";

            assertThat(post(staging, "/api/otp/verify",
                    Map.of("phone", number.phone(), "code", wrong))).isEqualTo(422);
            assertThat(post(staging, "/api/otp/verify",
                    Map.of("phone", number.phone(), "code", code))).isEqualTo(200);
        } finally {
            sms.release(number, gotSms); // có SMS thì finish, không thì hủy để hoàn tiền
        }
    }

    private static int post(RestClient client, String path, Map<String, String> body) {
        return client.post().uri(path)
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .exchange((request, response) -> response.getStatusCode().value());
    }

    private static String requireEnv(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException("Thiếu biến môi trường " + name);
        }
        return value;
    }
}
  • @Tag để job cho PR loại test này ra; @EnabledIfEnvironmentVariable là lớp bảo vệ cho máy dev không có key.
  • Smoke test chỉ có ý nghĩa khi staging gửi SMS qua gateway thật, với template giống production.

Chạy trên GitHub Actions: PR và smoke test theo lịch

Job cho PR loại tag real-sms ra. ubuntu-latest có sẵn Docker nên Testcontainers chạy được ngay.

# .github/workflows/tests.yml
name: tests
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '25'
          cache: maven
      - run: mvn -B -ntp test -DexcludedGroups=real-sms

Job theo lịch chỉ chạy tag real-sms, và không cần Docker vì test này là JUnit thuần:

# .github/workflows/otp-smoke.yml
name: otp-smoke
on:
  schedule:
    - cron: '17 7 * * 1-5' # 7h17 theo giờ Việt Nam, thứ 2 đến thứ 6
      timezone: 'Asia/Ho_Chi_Minh'
  workflow_dispatch:

concurrency:
  group: otp-smoke
  cancel-in-progress: false # run bị hủy giữa chừng thì finally không kịp chạy

jobs:
  real-sms:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '25'
          cache: maven
      - name: Smoke test OTP với số thật
        env:
          HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
          OTP_SERVICE: ${{ vars.OTP_SERVICE }}
          OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
          HEROSMS_MAX_PRICE: ${{ vars.HEROSMS_MAX_PRICE }}
          STAGING_URL: ${{ vars.STAGING_URL }}
        run: |
          test -n "$HEROSMS_API_KEY" || { echo "::error::Thiếu secret HEROSMS_API_KEY"; exit 1; }
          mvn -B -ntp test -Dgroups=real-sms -DfailIfNoTests=true
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: surefire-reports
          path: target/surefire-reports
  • test -n làm job đỏ khi secret bị xóa hoặc đổi tên. Thiếu dòng này, @EnabledIfEnvironmentVariable skip test và job cứ xanh mãi.
  • failIfNoTests của Surefire mặc định là false, nên gõ sai tag có thể ra job xanh với 0 test. -DfailIfNoTests=true làm job đỏ là mình suy ra từ docs Surefire, nên hãy thử gõ sai tag một lần cho chắc.
  • Không khai báo timezone thì cron tính theo UTC. Phút lẻ là cố ý, vì run theo lịch có thể trễ lúc tải cao, nhất là đầu mỗi giờ.
  • Không có trigger pull_request, nên không PR nào tiêu được số dư.
  • Runner phải gọi được staging qua internet, nếu không thì dùng self-hosted runner.

Chi phí và dùng đúng mục đích

Mỗi lần chạy thuê đúng một số. Giá tùy dịch vụ và quốc gia; HEROSMS_MAX_PRICE đặt mức trần. Lịch thứ 2 đến thứ 6 tốn khoảng 22 số mỗi tháng, cộng các lần chạy tay. Không có SMS thì release() hủy activation và tiền về lại số dư; SMS đã về thì vẫn phải trả tiền, kể cả khi app từ chối mã. Nếu runner chết hẳn, finally không chạy: số chưa dùng vẫn được hoàn, và GET /activations liệt kê các activation còn mở.

Chỉ test app của bạn hoặc app bạn được phép test. Số thuê chỉ thuộc về bạn trong thời gian activation, nên đừng gắn nó với tài khoản cần dùng lâu dài.

Lỗi thường gặp và cách xử lý

EOFException: EOF reached while reading

Xem phần bẫy ở trên: ép client về HTTP/1.1, hoặc bật http2PlainDisabled(true) trên WireMock.

Requested port (6379) is not mapped

Thiếu .withExposedPorts(6379). Mẫu Redis trong docs Boot không có dòng này, nhưng source của Boot đọc getMappedPort(6379) và docs Testcontainers yêu cầu expose từng port. Đây là suy luận, mình chưa tái hiện lỗi này.

Connect to http://localhost:80 với wiremock-spring-boot 4.4.0 hoặc 4.4.1

Issue #210: hai bản này reset WireMock client tĩnh cả ở test Spring không dùng @EnableWireMock, nên test dùng @WireMockTest gọi nhầm sang port 80 từ method thứ hai. Bản 4.4.2 đã sửa, trong bài dùng 4.4.3.

Không import được @MockBean, @WebMvcTest hoặc @AutoConfigureMockMvc

Boot 4 đã bỏ @MockBean và @SpyBean, thay bằng @MockitoBean và @MockitoSpyBean của Spring Framework; mock mặc định được reset sau mỗi test. @WebMvcTest và @AutoConfigureMockMvc chuyển sang org.springframework.boot.webmvc.test.autoconfigure, cần spring-boot-starter-webmvc-test.

Job xanh nhưng không có test nào chạy

Ba nguyên nhân: gõ sai tag; tên class không khớp *Test, *Tests, Test*, *TestCase nên Surefire bỏ qua; thiếu biến môi trường nên test bị skip.

Smoke test không nhận được mã

Kiểm tra theo thứ tự: staging có gửi qua gateway thật không; app có nhận số dạng +<mã quốc gia><số> không (form tách mã quốc gia thì cắt countryPhoneCode khỏi phone); OTP_SERVICE có khớp dịch vụ đang gửi SMS không, vì activation chỉ dành cho một dịch vụ. App chặn vài đầu số thì thêm phoneException vào body request mua. Muốn hiểu thêm vì sao OTP không tới, nhìn từ phía người nhận, bạn có thể đọc Không nhận được mã OTP khi đăng ký: 7 lý do và cách xử lý.

Lỗi 429 Too Many Requests

Nếu 429 đến từ HeroSMS: vòng poll đã đọc Retry-After. Nếu đến từ chính app: mỗi số chỉ được gửi 3 lần mỗi phút, nên smoke test mà tự retry bước gửi thì phải tính tới giới hạn này.

Activation đã đóng hoặc hết hạn

Vòng poll dừng ngay khi title báo đã đóng. Deadline 2 phút nằm gọn trong khoảng 20 phút của activation; muốn poll lâu hơn thì so với expiredAt.

Lịch GitHub Actions không chạy

Lịch chỉ chạy từ default branch, nên workflow phải được merge vào đó. Repo public không hoạt động 60 ngày thì schedule bị tắt. Dùng workflow_dispatch để thử ngay.

FAQ

Dùng @RestClientTest hay WireMock?

@RestClientTest (cần spring-boot-starter-restclient-test) cung cấp MockRestServiceServer, nhanh và không mở socket. Khi bean dùng RestClient.Builder, expectation phải ghi URI đầy đủ. Không có socket nên timeout và lỗi kết nối chỉ được giả lập bằng withException, muốn thấy chúng xảy ra thật thì dùng WireMock.

Có cần số thật ở mỗi PR không?

Không. Mock bắt lỗi logic ở mỗi PR, số thật chạy theo lịch và trước release là đủ.

Có cần testcontainers-redis của com.redis không?

Không. org.testcontainers:testcontainers-redis không có trên Central, còn com.redis:testcontainers-redis là thư viện bên thứ ba. GenericContainer với @ServiceConnection(name = "redis") là đủ.

Dùng webhook thay polling được không?

Được, API có webhook sms-incoming đẩy SMS tới URL của bạn, nhưng runner cần một URL public để nhận, nên với smoke test Java thì poll đơn giản hơn. Cách dựng webhook mình đã viết trong bài Playwright nhận OTP qua webhook.

Kết luận

Mock ở mỗi PR trả lời câu hỏi "code có đúng không", số thật theo lịch trả lời câu hỏi "SMS có tới tay user không". Checklist trước khi merge:

  • MockMvcTester với Redis thật: mã đúng, mã sai trả 422, hết hạn, dùng lại mã, lần gửi thứ 4 trả 429.
  • WireMock có case 500, mất kết nối, timeout; production và test dùng cùng một client HTTP.
  • Không retry request mua, poll có deadline, finally luôn finish hoặc hủy activation.
  • PR chạy -DexcludedGroups=real-sms, job theo lịch chạy -Dgroups=real-sms; key nằm trong secrets và có bước kiểm tra.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí