TDD Spring Boot — CultivaPay API

Ciclo red-green-refactor · POST /api/v1/payments · PaymentService + PaymentController

Spring Boot 3.2 JUnit 5 + Mockito TDD Workflow JaCoCo ≥ 80 %
🔴 RED Escribir test que falla
🟢 GREEN Código mínimo para pasar
🔵 REFACTOR Limpiar sin romper tests
93 %
Instrucciones
88 %
Ramas
91 %
Métodos
85 %
Líneas
Resultados de tests — 12 / 12 pasados ✓
processPayment_whenValidRequest_createsTransactionPending PaymentServiceTest 43 ms
processPayment_whenMerchantNotFound_throwsMerchantNotFoundException PaymentServiceTest 12 ms
processPayment_whenAmountZero_throwsInvalidAmountException PaymentServiceTest 8 ms
processPayment_whenAmountExceedsMax_throwsInvalidAmountException PaymentServiceTest 7 ms
createPayment_whenValidBody_returns201WithTransactionId PaymentControllerTest 124 ms
createPayment_whenMerchantNotFound_returns404 PaymentControllerTest 38 ms
createPayment_whenInvalidAmount_returns422 PaymentControllerTest 29 ms
createPayment_whenMissingFields_returns400 PaymentControllerTest 22 ms
createPayment_endToEnd_persistsAndPublishesEvent PaymentIntegrationTest 1 247 ms
findByMerchantId_returnsAllTransactionsForMerchant PaymentRepositoryTest 312 ms
processPayment_currencyVariants_allPassValidation PaymentServiceTest (Parameterized) 18 ms
processPayment_whenMerchantInactive_throwsInactiveMerchantException PaymentServiceTest 9 ms
🔴 RED — Test unitario (PaymentServiceTest.java)
🧪 src/test/java/com/cultivapay/payment/PaymentServiceTest.java
● FAIL (antes de impl)✓ PASS
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock MerchantRepository merchantRepository;
    @Mock TransactionRepository transactionRepository;
    @Mock RedisEventPublisher   eventPublisher;
    @InjectMocks PaymentService service;

    @Test
    void processPayment_whenValidRequest_createsTransactionPending() {
        // Arrange
        var merchant = new Merchant("mer_123", "CULTIVA Store", MerchantStatus.ACTIVE);
        var req      = new PaymentRequest("mer_123", 4999L, "EUR", "Suscripción CULTIVA IA");
        when(merchantRepository.findById("mer_123")).thenReturn(Optional.of(merchant));
        when(transactionRepository.save(any())).thenAnswer(inv -> {
            Transaction t = inv.getArgument(0);
            t.setId("txn_abc789");
            return t;
        });

        // Act
        PaymentResult result = service.processPayment(req);

        // Assert
        assertThat(result.transactionId()).isEqualTo("txn_abc789");
        assertThat(result.status()).isEqualTo(TransactionStatus.PENDING);
        verify(transactionRepository).save(argThat(t ->
            t.getAmount() == 4999L && t.getStatus() == TransactionStatus.PENDING
        ));
        verify(eventPublisher).publish("payment.created", any());
    }

    @Test
    void processPayment_whenMerchantNotFound_throwsMerchantNotFoundException() {
        when(merchantRepository.findById(any())).thenReturn(Optional.empty());
        assertThatThrownBy(() -> service.processPayment(
            new PaymentRequest("mer_unknown", 100L, "EUR", "Test")
        )).isInstanceOf(MerchantNotFoundException.class);
    }

    @ParameterizedTest
    @ValueSource(longs = {0L, -1L, 100_001L})
    void processPayment_whenAmountInvalid_throwsInvalidAmountException(long amount) {
        when(merchantRepository.findById(any())).thenReturn(Optional.of(activeMerchant()));
        assertThatThrownBy(() -> service.processPayment(
            new PaymentRequest("mer_123", amount, "EUR", "Test")
        )).isInstanceOf(InvalidAmountException.class);
    }
}
🟢 GREEN — Implementación mínima (PaymentService.java)
src/main/java/com/cultivapay/payment/PaymentService.java
✓ 12/12 PASS
@Service
@Transactional
public class PaymentService {

    private static final long MIN_AMOUNT = 1L;
    private static final long MAX_AMOUNT = 100_000L;

    private final MerchantRepository    merchantRepository;
    private final TransactionRepository  transactionRepository;
    private final RedisEventPublisher    eventPublisher;

    public PaymentResult processPayment(PaymentRequest req) {
        // Guard: montante válido
        if (req.amount() < MIN_AMOUNT || req.amount() > MAX_AMOUNT) {
            throw new InvalidAmountException(req.amount());
        }
        // Guard: comerciante activo
        Merchant merchant = merchantRepository
            .findById(req.merchantId())
            .orElseThrow(() -> new MerchantNotFoundException(req.merchantId()));
        if (merchant.status() != MerchantStatus.ACTIVE) {
            throw new InactiveMerchantException(req.merchantId());
        }
        // Persistir transacción PENDING
        Transaction txn = transactionRepository.save(
            Transaction.builder()
                .merchantId(req.merchantId())
                .amount(req.amount())
                .currency(req.currency())
                .description(req.description())
                .status(TransactionStatus.PENDING)
                .createdAt(Instant.now())
                .build()
        );
        // Publicar evento Redis
        eventPublisher.publish("payment.created", new PaymentCreatedEvent(txn));
        return new PaymentResult(txn.getId(), TransactionStatus.PENDING);
    }
}
MockMvc — Capa Web
🌐 PaymentControllerTest.java
✓ 4/4
@WebMvcTest(PaymentController.class)
class PaymentControllerTest {

  @Autowired MockMvc mockMvc;
  @MockBean  PaymentService paymentService;

  @Test
  void createPayment_whenValidBody_returns201()
      throws Exception {
    when(paymentService.processPayment(any()))
      .thenReturn(new PaymentResult(
        "txn_abc789", TransactionStatus.PENDING
      ));

    mockMvc.perform(post("/api/v1/payments")
        .contentType(APPLICATION_JSON)
        .content("""
          {
            "merchantId": "mer_123",
            "amount": 4999,
            "currency": "EUR",
            "description": "Suscripción CULTIVA IA"
          }
        """))
      .andExpect(status().isCreated())
      .andExpect(jsonPath("$.transactionId")
        .value("txn_abc789"))
      .andExpect(jsonPath("$.status")
        .value("PENDING"));
  }

  @Test
  void createPayment_whenMerchantNotFound_404()
      throws Exception {
    doThrow(new MerchantNotFoundException("x"))
      .when(paymentService).processPayment(any());
    mockMvc.perform(post("/api/v1/payments")
        .contentType(APPLICATION_JSON)
        .content("..."))
      .andExpect(status().isNotFound());
  }
}
Testcontainers — Integración
🐳 PaymentIntegrationTest.java
✓ 1/1 (1.2 s)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class PaymentIntegrationTest {

  @Autowired MockMvc mockMvc;
  @Autowired TransactionRepository repo;

  // PostgreSQL + Redis via Testcontainers
  @Import(TestContainersConfig.class)
  static class Cfg {}

  @Test
  void createPayment_endToEnd_persistsAndPublishes()
      throws Exception {
    mockMvc.perform(post("/api/v1/payments")
        .contentType(APPLICATION_JSON)
        .content("""
          {
            "merchantId": "mer_123",
            "amount": 4999,
            "currency": "EUR",
            "description": "Plan Pro CULTIVA IA"
          }
        """))
      .andExpect(status().isCreated())
      .andExpect(jsonPath("$.status")
        .value("PENDING"));

    // Verificar persistencia real en Postgres
    assertThat(repo.count()).isEqualTo(1);
    Transaction saved = repo.findAll().get(0);
    assertThat(saved.getStatus())
      .isEqualTo(TransactionStatus.PENDING);
    assertThat(saved.getAmount())
      .isEqualTo(4999L);
  }
}
JaCoCo — Exigir ≥ 80 % de cobertura (pom.xml)
📦 pom.xml — plugin JaCoCo
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.14</version>
  <executions>
    <execution><goals><goal>prepare-agent</goal></goals></execution>
    <execution>
      <id>report</id>
      <phase>verify</phase>
      <goals><goal>report</goal></goals>
    </execution>
    <execution>
      <id>check</id>
      <phase>verify</phase>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules><rule>
          <limits>
            <limit>
              <counter>INSTRUCTION</counter>
              <value>COVEREDRATIO</value>
              <minimum>0.80</minimum>    <!-- BUILD FAILS si < 80% -->
            </limit>
            <limit>
              <counter>BRANCH</counter>
              <value>COVEREDRATIO</value>
              <minimum>0.80</minimum>
            </limit>
          </limits>
        </rule></rules>
      </configuration>
    </execution>
  </executions>
</plugin>