E4 — Plan de Pruebas y Suite Automatizada¶
Competencia CE0241 · Pruebas unitarias, de integración y funcionales
Proyecto: LOGYX — Sistema Operativo Logístico Colaborativo para PYMEs
Equipo: Jorge Gutiérrez Miranda · Fabrizio Sanchez Saravia · Alex Coila Jarita
Versión: 1.0 · Junio 2026
1. Información General¶
| Campo | Detalle |
|---|---|
| Framework de pruebas (backend) | JUnit 5 + Quarkus Test + Mockito + RestAssured |
| Framework de pruebas (frontend web) | Jasmine + Karma (unit) · Cypress (E2E) |
| Framework de pruebas (Flutter) | flutter_test (unit + widget) · integration_test |
| Base de datos de pruebas | PostgreSQL 16 en Docker (Testcontainers) — ver nota de implementación abajo |
| Cobertura objetivo | ≥ 70% en módulos de negocio críticos |
| Integración | GitHub Actions (ver E4_CICD.md) |
Nota de implementación (agregada 2026-07-06): los módulos ya construidos (Auth, Organizations, Profiles, Vehicles, Drivers, Marketplace, Auctions, Bids, Pricing) no usan Testcontainers ni Mockito
@InjectMockcomo se planificó aquí. Se optó por@QuarkusTestcontra el Postgres y Redis reales dedocker-compose.yml(los mismos contenedores de desarrollo, sin mocks de repositorio), con fixtures de datos "find-or-create" por UUID fijo en cada@BeforeEachy@TestSecurity/@JwtSecuritypara simular el JWT. Los tests reales viven junto a cada módulo:src/test/java/pe/logyx/{auth,organization,profile,vehicle,driver,marketplace,auction,bid,pricing}/. Los casos TC-XXX de este documento describen la intención/cobertura correctamente, pero el código Java de ejemplo (estiloUni<T>,Profile.builder(),@InjectMock) no corresponde a cómo se escribió el código real — ver los archivos*ResourceTest.javade cada paquete para la versión vigente.
2. Tipos de Pruebas¶
2.1 Pruebas Unitarias¶
Validan lógica aislada de servicios sin dependencias externas. Usan Mockito para inyecciones.
2.2 Pruebas de Integración¶
Validan el stack completo (API → Servicio → Repositorio → BD real via Testcontainers).
2.3 Pruebas Funcionales / E2E¶
Validan flujos completos desde la perspectiva del usuario (Cypress en web, integration_test en Flutter).
3. Casos de Prueba — Backend (JUnit 5 + Quarkus Test)¶
3.1 Módulo AUTH¶
// AuthServiceTest.java
@QuarkusTest
class AuthServiceTest {
@Inject AuthService authService;
@InjectMock ProfileRepository profileRepo;
@Test
@DisplayName("TC-AUTH-01: Login exitoso retorna par de tokens")
void loginSuccessReturnsTokenPair() {
Profile mockProfile = Profile.builder()
.email("test@logyx.pe")
.passwordHash(BCrypt.hashpw("Password1!", BCrypt.gensalt(12)))
.role(ProfileRole.ADMIN)
.build();
when(profileRepo.findByEmail("test@logyx.pe"))
.thenReturn(Uni.createFrom().item(mockProfile));
TokenPair result = authService
.authenticate("test@logyx.pe", "Password1!")
.await().indefinitely();
assertNotNull(result.accessToken());
assertNotNull(result.refreshToken());
assertTrue(result.accessToken().split("\\.").length == 3); // formato JWT
}
@Test
@DisplayName("TC-AUTH-02: Login con contraseña incorrecta lanza UnauthorizedException")
void loginWrongPasswordThrows() {
Profile mockProfile = Profile.builder()
.email("test@logyx.pe")
.passwordHash(BCrypt.hashpw("OtraPass", BCrypt.gensalt(12)))
.build();
when(profileRepo.findByEmail("test@logyx.pe"))
.thenReturn(Uni.createFrom().item(mockProfile));
assertThrows(UnauthorizedException.class, () ->
authService.authenticate("test@logyx.pe", "Password1!")
.await().indefinitely()
);
}
@Test
@DisplayName("TC-AUTH-03: Login con usuario inexistente lanza UnauthorizedException")
void loginUnknownEmailThrows() {
when(profileRepo.findByEmail(anyString()))
.thenReturn(Uni.createFrom().nullItem());
assertThrows(UnauthorizedException.class, () ->
authService.authenticate("noexiste@logyx.pe", "Pass")
.await().indefinitely()
);
}
}
3.2 Módulo AUCTION¶
// AuctionServiceTest.java
@QuarkusTest
class AuctionServiceTest {
@Inject AuctionService auctionService;
@InjectMock AuctionRepository auctionRepo;
@InjectMock BidRepository bidRepo;
@InjectMock VehicleRepository vehicleRepo;
@Test
@DisplayName("TC-AUC-01: Oferta válida se persiste correctamente")
void validBidIsPersisted() {
UUID auctionId = UUID.randomUUID();
UUID carrierId = UUID.randomUUID();
UUID vehicleId = UUID.randomUUID();
Auction auction = buildOpenAuction(auctionId, new BigDecimal("2800.00")); // price_floor=2100
Vehicle vehicle = buildVehicle(vehicleId, new BigDecimal("15000"), BigDecimal.ZERO);
when(auctionRepo.findById(auctionId)).thenReturn(Uni.createFrom().item(auction));
when(vehicleRepo.findById(vehicleId)).thenReturn(Uni.createFrom().item(vehicle));
when(bidRepo.persist(any())).thenAnswer(inv -> Uni.createFrom().item(inv.getArgument(0)));
PlaceBidRequest req = new PlaceBidRequest(vehicleId, new BigDecimal("2500.00"), "Disponible mañana");
Bid result = auctionService.placeBid(auctionId, req, carrierId)
.await().indefinitely();
assertEquals(new BigDecimal("2500.00"), result.amount);
assertEquals(BidStatus.PENDING, result.status);
}
@Test
@DisplayName("TC-AUC-02: Oferta por debajo del price_floor lanza BusinessException")
void bidBelowPriceFloorThrows() {
UUID auctionId = UUID.randomUUID();
Auction auction = buildOpenAuction(auctionId, new BigDecimal("2800.00")); // floor=2100
when(auctionRepo.findById(auctionId)).thenReturn(Uni.createFrom().item(auction));
when(vehicleRepo.findById(any())).thenReturn(Uni.createFrom().item(buildVehicle(
UUID.randomUUID(), new BigDecimal("15000"), BigDecimal.ZERO)));
PlaceBidRequest req = new PlaceBidRequest(UUID.randomUUID(), new BigDecimal("1500.00"), "");
BusinessException ex = assertThrows(BusinessException.class, () ->
auctionService.placeBid(auctionId, req, UUID.randomUUID())
.await().indefinitely()
);
assertTrue(ex.getMessage().contains("LOGYX-BID-001"));
}
@Test
@DisplayName("TC-AUC-03: Oferta rechazada si vehículo no tiene capacidad")
void bidRejectedWhenVehicleAtCapacity() {
UUID auctionId = UUID.randomUUID();
Auction auction = buildOpenAuction(auctionId, new BigDecimal("2800.00"));
// Vehículo al 100% de capacidad
Vehicle vehicle = buildVehicle(UUID.randomUUID(),
new BigDecimal("8000"), new BigDecimal("8000"));
when(auctionRepo.findById(auctionId)).thenReturn(Uni.createFrom().item(auction));
when(vehicleRepo.findById(any())).thenReturn(Uni.createFrom().item(vehicle));
PlaceBidRequest req = new PlaceBidRequest(vehicle.id, new BigDecimal("2500.00"), "");
assertThrows(BusinessException.class, () ->
auctionService.placeBid(auctionId, req, UUID.randomUUID())
.await().indefinitely()
);
}
@Test
@DisplayName("TC-AUC-04: No se puede ofertar en subasta cerrada")
void cannotBidOnClosedAuction() {
UUID auctionId = UUID.randomUUID();
Auction auction = buildOpenAuction(auctionId, new BigDecimal("2800.00"));
auction.status = AuctionStatus.CLOSED;
when(auctionRepo.findById(auctionId)).thenReturn(Uni.createFrom().item(auction));
assertThrows(BusinessException.class, () ->
auctionService.placeBid(auctionId,
new PlaceBidRequest(UUID.randomUUID(), new BigDecimal("2500.00"), ""),
UUID.randomUUID())
.await().indefinitely()
);
}
}
3.3 Módulo PRICING¶
// PricingServiceTest.java
@QuarkusTest
class PricingServiceTest {
@Inject PricingService pricingService;
@InjectMock OpenRouteServiceClient orsClient;
@Test
@DisplayName("TC-PRICE-01: Cálculo correcto para carga general Lima-Arequipa")
void correctCalculationGeneralCargoLimaArequipa() {
OrsRoute mockRoute = new OrsRoute(
new BigDecimal("1014500"), // 1014.5 km en metros
new BigDecimal("46800") // 780 min en segundos
);
when(orsClient.getRoute(anyDouble(), anyDouble(), anyDouble(), anyDouble()))
.thenReturn(Uni.createFrom().item(mockRoute));
ShipmentRequest req = ShipmentRequest.builder()
.weightKg(new BigDecimal("3500"))
.cargoType(CargoType.GENERAL)
.originLat(-12.0731).originLng(-77.0826)
.destinationLat(-16.3989).destinationLng(-71.5370)
.build();
CostCalculation result = pricingService.calculate(req).await().indefinitely();
assertTrue(result.distanceKm.compareTo(new BigDecimal("1014")) > 0);
assertTrue(result.totalEstimate.compareTo(BigDecimal.ZERO) > 0);
// Verificar que el total es la suma de los componentes
BigDecimal expectedTotal = result.fuelCost
.add(result.baseTransportCost)
.add(result.tollCost)
.add(result.handlingFee);
assertEquals(0, result.totalEstimate.compareTo(expectedTotal));
}
@Test
@DisplayName("TC-PRICE-02: Carga frágil aplica multiplicador 1.30")
void fragileCargoPriceMultiplierApplied() {
OrsRoute route = new OrsRoute(new BigDecimal("500000"), new BigDecimal("18000"));
when(orsClient.getRoute(anyDouble(), anyDouble(), anyDouble(), anyDouble()))
.thenReturn(Uni.createFrom().item(route));
ShipmentRequest general = buildRequest(new BigDecimal("1000"), CargoType.GENERAL);
ShipmentRequest fragile = buildRequest(new BigDecimal("1000"), CargoType.FRAGILE);
CostCalculation calcGeneral = pricingService.calculate(general).await().indefinitely();
CostCalculation calcFragile = pricingService.calculate(fragile).await().indefinitely();
BigDecimal ratio = calcFragile.baseTransportCost
.divide(calcGeneral.baseTransportCost, 2, HALF_UP);
assertEquals(0, ratio.compareTo(new BigDecimal("1.30")));
}
}
3.4 Módulo MODERATION (función fn_contains_contact_info)¶
// MessageModerationServiceTest.java
@QuarkusTest
class MessageModerationServiceTest {
@Inject MessageModerationService moderator;
@ParameterizedTest
@DisplayName("TC-MOD-01: Detecta teléfonos peruanos de 9 dígitos")
@ValueSource(strings = {
"Llámame al 987654321",
"Mi cel es 912345678 para coordinar",
"987123456 precio mejor afuera"
})
void detectsPeruvianPhoneNumbers(String text) {
assertTrue(moderator.containsContactInfo(text),
"Debería detectar número de teléfono en: " + text);
}
@ParameterizedTest
@DisplayName("TC-MOD-02: Detecta emails")
@ValueSource(strings = {
"Escríbeme a carlos@transportes.com",
"correo: juan.perez@gmail.com para cotizar"
})
void detectsEmails(String text) {
assertTrue(moderator.containsContactInfo(text));
}
@ParameterizedTest
@DisplayName("TC-MOD-03: No bloquea mensajes legítimos")
@ValueSource(strings = {
"Puedo salir mañana con 3 toneladas",
"El precio incluye peajes en la ruta",
"Tengo disponibilidad para el 5 de julio"
})
void doesNotBlockLegitimateMessages(String text) {
assertFalse(moderator.containsContactInfo(text));
}
}
3.5 Pruebas de Integración — API REST¶
// ShipmentRequestResourceIT.java
@QuarkusIntegrationTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ShipmentRequestResourceIT {
static String accessToken;
static UUID createdRequestId;
@BeforeAll
static void login() {
accessToken = given()
.contentType(ContentType.JSON)
.body("""{"email":"maria.torres@textilesarequipa.pe","password":"Test1234!"}""")
.when().post("/api/v1/auth/login")
.then().statusCode(200)
.extract().path("data.accessToken");
}
@Test @Order(1)
@DisplayName("TC-IT-01: PYME puede publicar solicitud de carga")
void pymeCanPublishShipmentRequest() {
String requestBody = """
{
"originAddress": "Av. La Marina 1200, Lima",
"originLat": -12.0731, "originLng": -77.0826,
"destinationAddress": "Mercaderes 120, Arequipa",
"destinationLat": -16.3989, "destinationLng": -71.5370,
"weightKg": 2000,
"cargoType": "general",
"requiredDate": "2026-07-15"
}
""";
createdRequestId = given()
.header("Authorization", "Bearer " + accessToken)
.contentType(ContentType.JSON)
.body(requestBody)
.when().post("/api/v1/shipment-requests")
.then()
.statusCode(201)
.body("data.status", equalTo("open"))
.body("data.suggestedPrice", notNullValue())
.extract().path("data.id");
assertNotNull(createdRequestId);
}
@Test @Order(2)
@DisplayName("TC-IT-02: La solicitud aparece en el marketplace")
void requestAppearsInMarketplace() {
given()
.header("Authorization", "Bearer " + accessToken)
.when().get("/api/v1/shipment-requests?status=open")
.then()
.statusCode(200)
.body("data", hasSize(greaterThanOrEqualTo(1)));
}
@Test @Order(3)
@DisplayName("TC-IT-03: Solicitud sin token retorna 401")
void unauthenticatedRequestReturns401() {
given()
.when().get("/api/v1/shipment-requests")
.then().statusCode(401);
}
}
4. Casos de Prueba — Flutter (flutter_test)¶
// auction_service_test.dart
void main() {
group('AuctionService', () {
late AuctionService service;
late MockApiService mockApi;
setUp(() {
mockApi = MockApiService();
service = AuctionService(api: mockApi);
});
test('TC-FL-01: placeBid retorna Bid al recibir 201', () async {
when(mockApi.post(any, any, any)).thenAnswer((_) async => {
'id': 'bid-uuid',
'amount': 2500.0,
'status': 'pending',
});
final bid = await service.placeBid(
auctionId: 'auction-uuid',
vehicleId: 'vehicle-uuid',
amount: 2500.0,
notes: 'Disponible hoy',
);
expect(bid.amount, equals(2500.0));
expect(bid.status, equals('pending'));
});
test('TC-FL-02: Error 400 por precio bajo lanza BusinessException', () async {
when(mockApi.post(any, any, any))
.thenThrow(ApiException(statusCode: 400, code: 'LOGYX-BID-001'));
expect(
() => service.placeBid(
auctionId: 'auction-uuid',
vehicleId: 'vehicle-uuid',
amount: 500.0,
notes: '',
),
throwsA(isA<BusinessException>()),
);
});
});
}
// delivery_confirm_widget_test.dart
void main() {
testWidgets('TC-FL-03: DeliveryConfirmScreen muestra botones de foto y firma',
(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: DeliveryConfirmScreen(
stopId: 'stop-uuid',
location: 'Av. Test 123',
),
),
);
expect(find.text('Tomar foto'), findsOneWidget);
expect(find.text('Capturar firma'), findsOneWidget);
expect(find.text('Confirmar entrega'), findsOneWidget);
});
testWidgets('TC-FL-04: No se puede confirmar sin foto y firma',
(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(home: DeliveryConfirmScreen(stopId: 'x', location: 'y')),
);
await tester.tap(find.text('Confirmar entrega'));
await tester.pump();
expect(find.text('Debes tomar la foto y capturar la firma'), findsOneWidget);
});
}
5. Casos de Prueba E2E — Cypress (Web)¶
// cypress/e2e/pyme_publish_request.cy.js
describe('PYME: Publicar solicitud de carga', () => {
beforeEach(() => {
cy.login('maria.torres@textilesarequipa.pe', 'Test1234!');
});
it('TC-E2E-01: PYME publica solicitud y ve cotización estimada', () => {
cy.visit('/pyme/requests/new');
// Paso 1: origen y destino
cy.get('[data-cy=origin-address]').type('Av. La Marina 1200, Lima');
cy.get('[data-cy=destination-address]').type('Mercaderes 120, Arequipa');
cy.get('[data-cy=next-step]').click();
// Paso 2: carga
cy.get('[data-cy=weight-kg]').type('2000');
cy.get('[data-cy=cargo-type]').select('general');
cy.get('[data-cy=required-date]').type('2026-07-15');
cy.get('[data-cy=next-step]').click();
// Paso 3: cotización
cy.get('[data-cy=estimated-price]').should('be.visible');
cy.get('[data-cy=distance-km]').should('contain.text', 'km');
cy.get('[data-cy=confirm-publish]').click();
// Verificar redirección a lista de solicitudes
cy.url().should('include', '/pyme/requests');
cy.get('[data-cy=request-status-badge]').first().should('contain', 'open');
});
it('TC-E2E-02: Carrier ve solicitud en marketplace y hace oferta', () => {
cy.loginAs('carlos@colcaexpress.pe', 'Test1234!');
cy.visit('/carrier/marketplace');
cy.get('[data-cy=bid-button]').first().click();
cy.get('[data-cy=bid-amount]').type('2400');
cy.get('[data-cy=bid-vehicle]').select('A3T-456');
cy.get('[data-cy=submit-bid]').click();
cy.get('[data-cy=success-toast]').should('contain', 'Oferta enviada');
});
});
6. Tabla de Casos de Prueba (Resumen)¶
| ID | Funcionalidad | Tipo | Resultado esperado | Estado |
|---|---|---|---|---|
| TC-AUTH-01 | Login correcto | Unit | TokenPair no nulo | Pendiente |
| TC-AUTH-02 | Login contraseña incorrecta | Unit | UnauthorizedException | Pendiente |
| TC-AUTH-03 | Login usuario inexistente | Unit | UnauthorizedException | Pendiente |
| TC-AUC-01 | Oferta válida | Unit | Bid persistida | Pendiente |
| TC-AUC-02 | Oferta bajo price_floor | Unit | BusinessException LOGYX-BID-001 | Pendiente |
| TC-AUC-03 | Oferta vehículo sin capacidad | Unit | BusinessException LOGYX-CAP-001 | Pendiente |
| TC-AUC-04 | Oferta en subasta cerrada | Unit | BusinessException LOGYX-AUC-001 | Pendiente |
| TC-PRICE-01 | Cálculo Lima-Arequipa general | Unit | Total > 0, suma coherente | Pendiente |
| TC-PRICE-02 | Multiplicador carga frágil | Unit | ratio = 1.30 | Pendiente |
| TC-MOD-01 | Detección teléfonos | Unit | containsContactInfo = true | Pendiente |
| TC-MOD-02 | Detección emails | Unit | containsContactInfo = true | Pendiente |
| TC-MOD-03 | Mensajes legítimos | Unit | containsContactInfo = false | Pendiente |
| TC-IT-01 | Publicar solicitud (API) | Integración | HTTP 201, status='open' | Pendiente |
| TC-IT-02 | Solicitud en marketplace | Integración | Lista no vacía | Pendiente |
| TC-IT-03 | Sin token retorna 401 | Integración | HTTP 401 | Pendiente |
| TC-FL-01 | placeBid Flutter OK | Unit Flutter | Bid retornada | Pendiente |
| TC-FL-02 | Error 400 Flutter | Unit Flutter | BusinessException | Pendiente |
| TC-FL-03 | UI confirmar entrega | Widget | Botones visibles | Pendiente |
| TC-FL-04 | Confirmar sin evidencia | Widget | Mensaje de error visible | Pendiente |
| TC-E2E-01 | PYME publica solicitud | E2E Cypress | Solicitud aparece en lista | Pendiente |
| TC-E2E-02 | Carrier hace oferta | E2E Cypress | Toast de confirmación | Pendiente |
7. Cobertura de Pruebas¶
| Módulo | Pruebas unitarias | Pruebas de integración | Cobertura estimada |
|---|---|---|---|
| auth | 3 casos | 2 vía IT | ~80% |
| auction | 4 casos | 1 vía IT | ~75% |
| pricing | 2 casos | — | ~70% |
| negotiation (moderation) | 3 casos | — | ~70% |
| tracking | — | Pendiente | ~50% |
| reputation | — | Pendiente | ~50% |
| fleet | — | Pendiente | ~40% |
| Total estimado | ≥ 65% |
LOGYX · E4 Plan de Pruebas · Competencia CE0241 · Versión 1.0 · Junio 2026