Estrategia de Testing de MedTime¶
Identificador: TECH-TST-001 Version: 1.1.0 Fecha: 2025-12-09 Ultima Revision: DV2-P3 - Remediacion de hallazgos de prioridad baja Autor: TestingDrone / SpecQueen Technical Division Refs Arquitectura: TECH-ARC-001, TECH-CS-001
1. Changelog¶
1.1. Version 1.1.0 (2025-12-09) - DV2-P3¶
- TEST-BAJO-001: Agregada seccion completa de Database Migration Tests (4.1.1)
- Tests de migracion iOS (Realm)
- Tests de migracion Android (Room)
- Tests de migracion PostgreSQL
- CI integration para migration tests
- TEST-BAJO-002: Agregada configuracion completa de Security Scanning Tools (6.4.1)
- SAST: Snyk, SonarQube, SwiftLint, Detekt, Semgrep, GitGuardian
- DAST: OWASP ZAP, MobSF
- Configuraciones detalladas y CI workflows
- TEST-BAJO-003: Agregados Load Testing Scenarios realistas (7.4.1)
- 6 scenarios completos: Smoke, Load, Stress, Spike, Soak, Concurrent
- Load test execution matrix
- CI integration con k6
- SLOs y metricas de performance
1.2. Version 1.0.0 (2025-12-08) - IT-06¶
- Creacion inicial del documento
- 1. Introduccion
- 1.1. Proposito
- 1.2. Alcance
- 1.3. Principios de Testing
- 2. Piramide de Testing
- 2.1. Distribucion de Tests
- 2.2. Cobertura por Capa
- 3. Unit Tests
- 3.1. Cliente iOS
- 3.2. Cliente Android
- 3.3. Servidor
- 3.4. Estrategias de Mocking
- 4. Integration Tests
- 4.1. Database Tests
- 4.2. API Contract Tests
- 4.3. Service Integration Tests
- 5. End-to-End Tests
- 5.1. Critical User Journeys
- 5.2. iOS UI Tests
- 5.3. Android UI Tests
- 6. Security Tests
- 6.1. Zero-Knowledge Tests
- 6.2. Cifrado E2E Tests
- 6.3. RLS Policy Tests
- 6.4. SAST y DAST
- 6.5. Penetration Testing
- 7. Performance Tests
- 7.1. Benchmarks Cliente
- 7.2. Benchmarks Servidor
- 7.3. Load Testing
- 8. Offline-First Tests
- 8.1. Escenarios Offline
- 8.2. Sync Conflict Tests
- 9. Test Data Management
- 9.1. Fixtures y Factories
- 9.2. Data Sanitization
- 10. CI/CD Integration
- 10.1. GitHub Actions Pipelines
- 10.2. Quality Gates
- 11. Matriz de Trazabilidad
- 12. Metricas y Reporting
- 13. Referencias
2. Introduccion¶
2.1. Proposito¶
Este documento define la estrategia de testing para MedTime, asegurando:
- Calidad del software en todas las capas
- Compliance con regulaciones (HIPAA, LGPD, FDA)
- Seguridad de datos de salud (PHI)
- Funcionamiento offline-first confiable
2.2. Alcance¶
| Componente | Incluido | Notas |
|---|---|---|
| iOS App | Si | Swift, Realm, XCTest |
| Android App | Si | Kotlin, Room, JUnit |
| Cloud Functions | Si | Node.js, Jest |
| PostgreSQL | Si | pgTAP, RLS tests |
| APIs | Si | Contract testing |
2.3. Principios de Testing¶
| Principio | Descripcion |
|---|---|
| Shift-Left | Testing temprano en el ciclo de desarrollo |
| Test Pyramid | Mas unit tests, menos E2E |
| TDD | Test-Driven Development donde aplique |
| Zero PHI in Tests | Nunca datos reales de pacientes |
| Reproducible | Tests deterministas y repetibles |
3. Piramide de Testing¶
3.1. Distribucion de Tests¶
graph TB
subgraph Piramide["PIRAMIDE DE TESTING"]
E2E["E2E Tests<br/>5% - Flujos criticos"]
INT["Integration Tests<br/>15% - APIs, DB, Services"]
UNIT["Unit Tests<br/>80% - Logica de negocio"]
end
E2E --> INT
INT --> UNIT
style E2E fill:#ff9999
style INT fill:#99ccff
style UNIT fill:#99ff99
3.2. Cobertura por Capa¶
| Capa | Cobertura Target | Cobertura Minima | Tipo Principal |
|---|---|---|---|
| Domain (Entities, UseCases) | 95% | 90% | Unit |
| Data (Repositories, DataSources) | 85% | 80% | Unit + Integration |
| Presentation (ViewModels) | 80% | 70% | Unit |
| Infrastructure (APIs, DB) | 90% endpoints | 85% | Integration |
| UI (Screens, Flows) | Critical paths | - | E2E |
4. Unit Tests¶
4.1. Cliente iOS¶
Framework: XCTest + Quick/Nimble
// Ejemplo: Test de UseCase de medicamentos
import XCTest
@testable import MedTime
class AddMedicationUseCaseTests: XCTestCase {
var sut: AddMedicationUseCase!
var mockRepository: MockMedicationRepository!
var mockEncryption: MockEncryptionService!
override func setUp() {
super.setUp()
mockRepository = MockMedicationRepository()
mockEncryption = MockEncryptionService()
sut = AddMedicationUseCase(
repository: mockRepository,
encryption: mockEncryption
)
}
func test_addMedication_success_encryptsAndSaves() async throws {
// Given
let medication = Medication.fixture()
// When
try await sut.execute(medication: medication)
// Then
XCTAssertTrue(mockEncryption.encryptCalled)
XCTAssertTrue(mockRepository.saveCalled)
XCTAssertEqual(mockRepository.savedMedication?.id, medication.id)
}
func test_addMedication_duplicateName_throwsError() async {
// Given
mockRepository.existsByNameResult = true
let medication = Medication.fixture()
// When/Then
await XCTAssertThrowsError(
try await sut.execute(medication: medication)
) { error in
XCTAssertEqual(error as? MedicationError, .duplicateName)
}
}
}
Cobertura por modulo:
| Modulo | Target | Critico |
|---|---|---|
| Domain/Entities | 95% | 90% |
| Domain/UseCases | 95% | 90% |
| Data/Repositories | 85% | 80% |
| Presentation/ViewModels | 80% | 70% |
4.2. Cliente Android¶
Framework: JUnit 5 + MockK + Turbine (Flow testing)
// Ejemplo: Test de ViewModel
class MedicationListViewModelTest {
@MockK
lateinit var getMedicationsUseCase: GetMedicationsUseCase
@MockK
lateinit var deleteMedicationUseCase: DeleteMedicationUseCase
private lateinit var viewModel: MedicationListViewModel
@Before
fun setup() {
MockKAnnotations.init(this)
viewModel = MedicationListViewModel(
getMedicationsUseCase,
deleteMedicationUseCase
)
}
@Test
fun `loadMedications success updates state with medications`() = runTest {
// Given
val medications = listOf(Medication.fixture(), Medication.fixture())
coEvery { getMedicationsUseCase() } returns flowOf(medications)
// When
viewModel.loadMedications()
// Then
viewModel.uiState.test {
val state = awaitItem()
assertEquals(medications, state.medications)
assertEquals(false, state.isLoading)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `deleteMedication success removes from list`() = runTest {
// Given
val medicationId = UUID.randomUUID()
coEvery { deleteMedicationUseCase(medicationId) } returns Unit
// When
viewModel.deleteMedication(medicationId)
// Then
coVerify { deleteMedicationUseCase(medicationId) }
}
}
4.3. Servidor¶
Framework: Jest + Supertest
// Ejemplo: Test de Cloud Function
describe('validateMedicationBlob', () => {
let mockFirestore;
beforeEach(() => {
mockFirestore = createMockFirestore();
jest.clearAllMocks();
});
test('should accept valid encrypted blob', async () => {
// Given
const validBlob = {
blob_id: 'uuid-here',
entity_type: 'medication',
encrypted_data: 'base64-encrypted-content',
checksum: 'sha256-hash',
version: 1
};
// When
const result = await validateMedicationBlob(validBlob);
// Then
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('should reject blob with invalid checksum', async () => {
// Given
const invalidBlob = {
blob_id: 'uuid-here',
encrypted_data: 'some-data',
checksum: 'wrong-checksum'
};
// When
const result = await validateMedicationBlob(invalidBlob);
// Then
expect(result.valid).toBe(false);
expect(result.errors).toContain('CHECKSUM_MISMATCH');
});
});
4.4. Estrategias de Mocking¶
| Capa | Que Mockear | Como |
|---|---|---|
| UseCase | Repository, Services | Protocol/Interface mocks |
| Repository | DataSource | In-memory implementations |
| ViewModel | UseCases | Mock returns |
| API Client | Network | Mock server (WireMock) |
| Database | - | In-memory SQLite/Realm |
Regla de oro: Mock las dependencias, no la implementacion.
5. Integration Tests¶
5.1. Database Tests¶
5.1.1. Database Migration Tests (DV2-P3)¶
Agregado: TEST-BAJO-001 - Tests completos para migraciones de base de datos.
Objetivo: Garantizar que las migraciones de esquema preservan datos y mantienen integridad referencial.
5.1.1.1. Migration Test Strategy¶
flowchart TD
subgraph Proceso["MIGRATION TEST PROCESS"]
S[Schema Version N] --> D[Populate Test Data]
D --> M[Apply Migration N+1]
M --> V[Validate Data Integrity]
V --> R[Validate Rollback]
R --> P[Performance Check]
end
style M fill:#ff9999
style V fill:#99ff99
5.1.1.2. iOS/Android Local DB Migrations¶
iOS (Realm) Migration Tests:
// tests/migrations/RealmMigrationTests.swift
class RealmMigrationTests: XCTestCase {
// MARK: - Migration v1 -> v2 (DV2-P3)
func test_migration_v1_to_v2_preservesAllData() throws {
// Given: Database v1 with test data
let v1Config = Realm.Configuration(
schemaVersion: 1,
deleteRealmIfMigrationNeeded: false
)
let v1Realm = try Realm(configuration: v1Config)
// Insert v1 data
try v1Realm.write {
v1Realm.add(MedicationV1(
id: UUID().uuidString,
name: "Metformin",
dosage: "500mg",
frequency: "twice_daily"
// No createdAt field in v1
))
v1Realm.add(MedicationV1(
id: UUID().uuidString,
name: "Lisinopril",
dosage: "10mg",
frequency: "once_daily"
))
}
let v1Count = v1Realm.objects(MedicationV1.self).count
XCTAssertEqual(v1Count, 2)
// When: Migrate to v2 (adds createdAt, updatedAt fields)
let v2Config = Realm.Configuration(
schemaVersion: 2,
migrationBlock: { migration, oldSchemaVersion in
if oldSchemaVersion < 2 {
migration.enumerateObjects(ofType: MedicationV2.className()) { old, new in
// Populate new timestamp fields
new!["createdAt"] = Date()
new!["updatedAt"] = Date()
}
}
}
)
let v2Realm = try Realm(configuration: v2Config)
// Then: All data preserved
let v2Medications = v2Realm.objects(MedicationV2.self)
XCTAssertEqual(v2Medications.count, 2)
// Verify migrated data
let metformin = v2Medications.first { $0.name == "Metformin" }
XCTAssertNotNil(metformin)
XCTAssertEqual(metformin?.dosage, "500mg")
XCTAssertNotNil(metformin?.createdAt, "New field should be populated")
XCTAssertNotNil(metformin?.updatedAt, "New field should be populated")
}
func test_migration_v2_to_v3_addsEncryptionMetadata() throws {
// Given: v2 database
let v2Config = Realm.Configuration(schemaVersion: 2)
let v2Realm = try Realm(configuration: v2Config)
try v2Realm.write {
v2Realm.add(MedicationV2(
id: UUID().uuidString,
name: "Aspirin",
dosage: "81mg"
// No encryption_version field
))
}
// When: Migrate to v3 (adds encryption_version, last_encrypted_at)
let v3Config = Realm.Configuration(
schemaVersion: 3,
migrationBlock: { migration, oldSchemaVersion in
if oldSchemaVersion < 3 {
migration.enumerateObjects(ofType: MedicationV3.className()) { old, new in
new!["encryption_version"] = 1
new!["last_encrypted_at"] = Date()
}
}
}
)
let v3Realm = try Realm(configuration: v3Config)
// Then: Encryption fields added
let medications = v3Realm.objects(MedicationV3.self)
XCTAssertEqual(medications.count, 1)
XCTAssertEqual(medications.first?.encryption_version, 1)
XCTAssertNotNil(medications.first?.last_encrypted_at)
}
func test_migration_rollback_restoresV1() throws {
// Given: v2 database
let v2Config = Realm.Configuration(schemaVersion: 2)
let v2Realm = try Realm(configuration: v2Config)
let medicationId = UUID().uuidString
try v2Realm.write {
v2Realm.add(MedicationV2(id: medicationId, name: "Test Med"))
}
// Create backup before migration
let backupPath = v2Config.fileURL!.appendingPathComponent(".backup")
try FileManager.default.copyItem(at: v2Config.fileURL!, to: backupPath)
// When: Migration fails (simulated)
// Restore from backup
try FileManager.default.removeItem(at: v2Config.fileURL!)
try FileManager.default.copyItem(at: backupPath, to: v2Config.fileURL!)
// Then: v2 data intact
let restoredRealm = try Realm(configuration: v2Config)
let medications = restoredRealm.objects(MedicationV2.self)
XCTAssertEqual(medications.count, 1)
XCTAssertEqual(medications.first?.id, medicationId)
}
func test_migration_performance_under500ms() throws {
// Given: v1 database with 1000 medications
let v1Config = Realm.Configuration(schemaVersion: 1)
let v1Realm = try Realm(configuration: v1Config)
try v1Realm.write {
for i in 0..<1000 {
v1Realm.add(MedicationV1(
id: UUID().uuidString,
name: "Med \(i)",
dosage: "100mg"
))
}
}
// When: Measure migration time
let startTime = Date()
let v2Config = Realm.Configuration(
schemaVersion: 2,
migrationBlock: MedTimeMigrations.v1ToV2
)
_ = try Realm(configuration: v2Config)
let duration = Date().timeIntervalSince(startTime)
// Then: Migration completes in < 500ms
XCTAssertLessThan(duration, 0.5, "Migration of 1000 records should complete in < 500ms")
}
}
Android (Room) Migration Tests:
// tests/migrations/RoomMigrationTest.kt
@RunWith(AndroidJUnit4::class)
class RoomMigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
// MARK: - Migration 1 -> 2 (DV2-P3)
@Test
fun migrate_1_to_2_preservesAllData() {
// Given: v1 database with data
val db = helper.createDatabase(TEST_DB, 1).apply {
execSQL("""
INSERT INTO medications (id, name, dosage, frequency)
VALUES ('uuid-1', 'Metformin', '500mg', 'twice_daily')
""")
execSQL("""
INSERT INTO medications (id, name, dosage, frequency)
VALUES ('uuid-2', 'Lisinopril', '10mg', 'once_daily')
""")
close()
}
// When: Migrate to v2 (adds created_at, updated_at columns)
val migratedDb = helper.runMigrationsAndValidate(
TEST_DB,
2,
true,
MIGRATION_1_2
)
// Then: Data preserved and new columns populated
val cursor = migratedDb.query("SELECT * FROM medications")
assertThat(cursor.count).isEqualTo(2)
cursor.moveToFirst()
assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Metformin")
assertThat(cursor.getLong(cursor.getColumnIndex("created_at"))).isGreaterThan(0)
assertThat(cursor.getLong(cursor.getColumnIndex("updated_at"))).isGreaterThan(0)
cursor.moveToNext()
assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Lisinopril")
cursor.close()
}
@Test
fun migrate_2_to_3_addsEncryptionFields() {
// Given: v2 database
val db = helper.createDatabase(TEST_DB, 2).apply {
execSQL("""
INSERT INTO medications (id, name, dosage, created_at, updated_at)
VALUES ('uuid-1', 'Aspirin', '81mg', ${System.currentTimeMillis()}, ${System.currentTimeMillis()})
""")
close()
}
// When: Migrate to v3 (adds encryption_version, last_encrypted_at)
val migratedDb = helper.runMigrationsAndValidate(
TEST_DB,
3,
true,
MIGRATION_2_3
)
// Then: Encryption fields added
val cursor = migratedDb.query("SELECT * FROM medications")
cursor.moveToFirst()
assertThat(cursor.getInt(cursor.getColumnIndex("encryption_version"))).isEqualTo(1)
assertThat(cursor.getLong(cursor.getColumnIndex("last_encrypted_at"))).isGreaterThan(0)
cursor.close()
}
@Test
fun migrate_1_to_3_skipsV2() {
// Given: v1 database
val db = helper.createDatabase(TEST_DB, 1).apply {
execSQL("""
INSERT INTO medications (id, name, dosage, frequency)
VALUES ('uuid-1', 'Test Med', '100mg', 'daily')
""")
close()
}
// When: Direct migration 1 -> 3
val migratedDb = helper.runMigrationsAndValidate(
TEST_DB,
3,
true,
MIGRATION_1_2,
MIGRATION_2_3
)
// Then: All fields present
val cursor = migratedDb.query("SELECT * FROM medications")
cursor.moveToFirst()
assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Test Med")
assertThat(cursor.getLong(cursor.getColumnIndex("created_at"))).isGreaterThan(0)
assertThat(cursor.getInt(cursor.getColumnIndex("encryption_version"))).isEqualTo(1)
cursor.close()
}
@Test
fun migration_performance_1000Records_under500ms() {
// Given: v1 database with 1000 records
val db = helper.createDatabase(TEST_DB, 1).apply {
beginTransaction()
try {
repeat(1000) { i ->
execSQL("""
INSERT INTO medications (id, name, dosage, frequency)
VALUES ('uuid-$i', 'Med $i', '100mg', 'daily')
""")
}
setTransactionSuccessful()
} finally {
endTransaction()
}
close()
}
// When: Measure migration time
val startTime = System.currentTimeMillis()
helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
val duration = System.currentTimeMillis() - startTime
// Then: Migration < 500ms
assertThat(duration).isLessThan(500)
}
}
5.1.1.3. PostgreSQL Server Migrations¶
Server Schema Migration Tests:
-- tests/db/migrations/test_migration_v1_to_v2.sql
BEGIN;
SELECT plan(8);
-- Test: Migration v1 -> v2 (adds encrypted_metadata column)
-- Given: v1 schema with test data
CREATE TABLE srv_encrypted_blobs_v1 (
blob_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
entity_type TEXT NOT NULL,
encrypted_data BYTEA NOT NULL,
version INT DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO srv_encrypted_blobs_v1 VALUES
('blob-1', 'user-1', 'medication', 'encrypted-content-1', 1, NOW()),
('blob-2', 'user-1', 'alert', 'encrypted-content-2', 1, NOW());
-- When: Apply migration
ALTER TABLE srv_encrypted_blobs_v1 ADD COLUMN encrypted_metadata JSONB DEFAULT '{}'::jsonb;
UPDATE srv_encrypted_blobs_v1 SET encrypted_metadata = '{"migrated": true}'::jsonb;
-- Then: Verify data preserved
SELECT ok(
(SELECT COUNT(*) FROM srv_encrypted_blobs_v1) = 2,
'All records preserved after migration'
);
SELECT ok(
(SELECT encrypted_data FROM srv_encrypted_blobs_v1 WHERE blob_id = 'blob-1') = 'encrypted-content-1',
'Encrypted data unchanged'
);
SELECT ok(
(SELECT encrypted_metadata->>'migrated' FROM srv_encrypted_blobs_v1 WHERE blob_id = 'blob-1') = 'true',
'New metadata column populated'
);
-- Test: Index creation performance
SELECT ok(
(SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'srv_encrypted_blobs_v1') >= 1,
'Indexes created'
);
-- Test: Rollback
SAVEPOINT before_rollback;
DROP TABLE srv_encrypted_blobs_v1;
ROLLBACK TO before_rollback;
SELECT ok(
(SELECT COUNT(*) FROM srv_encrypted_blobs_v1) = 2,
'Rollback restores data'
);
SELECT * FROM finish();
ROLLBACK;
5.1.1.4. Migration Test Checklist¶
- All existing data preserved
- New columns/fields populated correctly
- Default values applied
- Indexes created/updated
- Foreign key constraints maintained
- Rollback tested
- Performance benchmarked (< 500ms per 1000 records)
- Migration idempotent (can re-run safely)
5.1.1.5. Migration CI Integration¶
# .github/workflows/migration-tests.yml
name: Database Migration Tests
on:
pull_request:
paths:
- 'ios/MedTime/Database/Migrations/**'
- 'android/app/src/main/java/com/medtime/data/migrations/**'
- 'server/db/migrations/**'
jobs:
ios-migrations:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Run Realm Migration Tests
run: |
xcodebuild test \
-scheme MedTime \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:MedTimeTests/RealmMigrationTests
android-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Room Migration Tests
run: ./gradlew testDebugUnitTest --tests "*RoomMigrationTest"
postgres-migrations:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v4
- name: Run PostgreSQL Migration Tests
run: |
psql -h localhost -U postgres -f tests/db/migrations/*.sql
PostgreSQL con pgTAP:
-- Test RLS: Usuario no puede ver datos de otro usuario
BEGIN;
SELECT plan(3);
-- Setup: Crear dos usuarios
INSERT INTO srv_users (user_id, firebase_uid, role, tier)
VALUES
('user-1-uuid', 'firebase-1', 'PI', 'pro'),
('user-2-uuid', 'firebase-2', 'PI', 'free');
-- Setup: Crear medicamentos para user-1
INSERT INTO srv_encrypted_blobs (blob_id, user_id, entity_type, encrypted_data)
VALUES ('blob-1', 'user-1-uuid', 'medication', 'encrypted-content');
-- Test 1: User-1 puede ver sus propios datos
SET app.firebase_uid = 'firebase-1';
SELECT ok(
(SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-1-uuid') = 1,
'User can see own data'
);
-- Test 2: User-2 NO puede ver datos de User-1
SET app.firebase_uid = 'firebase-2';
SELECT ok(
(SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-1-uuid') = 0,
'User cannot see other users data due to RLS'
);
-- Test 3: Sin firebase_uid, no ve nada
SET app.firebase_uid = '';
SELECT ok(
(SELECT COUNT(*) FROM srv_encrypted_blobs) = 0,
'No data visible without valid firebase_uid'
);
SELECT * FROM finish();
ROLLBACK;
Cliente SQLite/Realm:
// iOS: Test de migracion de Realm
class RealmMigrationTests: XCTestCase {
func test_migration_v1_to_v2_preservesData() throws {
// Given: Database v1
let v1Config = Realm.Configuration(
schemaVersion: 1,
deleteRealmIfMigrationNeeded: false
)
let v1Realm = try Realm(configuration: v1Config)
try v1Realm.write {
v1Realm.add(MedicationV1(name: "Metformin", dosage: "500mg"))
}
// When: Migrate to v2
let v2Config = Realm.Configuration(
schemaVersion: 2,
migrationBlock: MedTimeMigrations.v1ToV2
)
let v2Realm = try Realm(configuration: v2Config)
// Then: Data preserved with new fields
let medications = v2Realm.objects(MedicationV2.self)
XCTAssertEqual(medications.count, 1)
XCTAssertEqual(medications.first?.name, "Metformin")
XCTAssertNotNil(medications.first?.createdAt) // New field
}
}
5.2. API Contract Tests¶
OpenAPI Validation con Prism/Spectral:
# .spectral.yaml - Reglas de validacion
extends: spectral:oas
rules:
# Todas las respuestas deben tener schema
operation-success-response:
severity: error
# Endpoints de PHI deben requerir auth
phi-endpoints-require-auth:
severity: error
given: "$.paths[*][*]"
then:
field: security
function: truthy
# No PHI en query params
no-phi-in-query:
severity: error
given: "$.paths[*][*].parameters[?(@.in=='query')]"
then:
field: name
function: pattern
functionOptions:
notMatch: "(name|medication|dose|patient)"
5.2.1. Contract Tests por API (DV2-P2)¶
Agregado: TEST-MEDIO-001 - Cobertura completa de contract tests para todas las APIs.
| API | Spec File | Contract Tests | Cobertura |
|---|---|---|---|
| API-SYNC-001 | apis/API-SYNC-001.yaml |
tests/contracts/sync.test.js |
100% |
| API-MED-001 | apis/API-MED-001.yaml |
tests/contracts/medications.test.js |
100% |
| API-RX-001 | apis/API-RX-001.yaml |
tests/contracts/prescriptions.test.js |
100% |
| API-ALT-001 | apis/API-ALT-001.yaml |
tests/contracts/alerts.test.js |
100% |
| API-NTF-001 | apis/API-NTF-001.yaml |
tests/contracts/notifications.test.js |
100% |
| API-EST-001 | apis/API-EST-001.yaml |
tests/contracts/statistics.test.js |
100% |
Contract Test Template:
// tests/contracts/sync.test.js
import { OpenAPIValidator } from 'express-openapi-validator';
import spec from '../apis/API-SYNC-001.yaml';
describe('API-SYNC-001 Contract Tests', () => {
const validator = new OpenAPIValidator({ spec });
describe('POST /sync/push', () => {
test('request body matches schema', async () => {
const validBody = {
changes: [{
blob_id: 'uuid-here',
entity_type: 'medication',
operation: 'upsert',
encrypted_data: 'base64-content',
version: 1
}],
client_timestamp: new Date().toISOString()
};
const errors = await validator.validateRequest({
path: '/sync/push',
method: 'POST',
body: validBody
});
expect(errors).toHaveLength(0);
});
test('batch size limits enforced (1-100)', async () => {
const tooManyChanges = Array(101).fill({
blob_id: 'uuid',
entity_type: 'medication',
operation: 'upsert',
encrypted_data: 'data'
});
const errors = await validator.validateRequest({
path: '/sync/push',
method: 'POST',
body: { changes: tooManyChanges }
});
expect(errors.some(e => e.message.includes('maxItems'))).toBe(true);
});
test('response matches 200 schema', async () => {
const response = {
sync_token: 'token-123',
processed: 5,
conflicts: [],
server_timestamp: new Date().toISOString()
};
const errors = await validator.validateResponse({
path: '/sync/push',
method: 'POST',
status: 200,
body: response
});
expect(errors).toHaveLength(0);
});
});
describe('GET /sync/pull', () => {
test('query params validated', async () => {
const errors = await validator.validateRequest({
path: '/sync/pull',
method: 'GET',
query: {
since: new Date().toISOString(),
entity_types: 'medication,alert'
}
});
expect(errors).toHaveLength(0);
});
test('page_size max 100 enforced', async () => {
const errors = await validator.validateRequest({
path: '/sync/pull',
method: 'GET',
query: { page_size: 150 }
});
expect(errors.some(e => e.message.includes('maximum'))).toBe(true);
});
});
});
Validacion Automatica en CI:
# .github/workflows/contract-tests.yml
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate OpenAPI Specs
run: |
npx @stoplight/spectral-cli lint technical-spec/apis/*.yaml
- name: Run Contract Tests
run: npm run test:contracts
- name: Generate Coverage Report
run: |
echo "## Contract Test Coverage" >> $GITHUB_STEP_SUMMARY
echo "| API | Endpoints | Covered |" >> $GITHUB_STEP_SUMMARY
npm run test:contracts:coverage >> $GITHUB_STEP_SUMMARY
Contract Test con Postman/Newman:
// tests/api/auth.test.js
pm.test("POST /auth/login returns JWT", function () {
pm.response.to.have.status(200);
pm.response.to.have.jsonBody("access_token");
pm.response.to.have.jsonBody("refresh_token");
// Validate JWT structure
const jwt = pm.response.json().access_token;
const parts = jwt.split('.');
pm.expect(parts.length).to.equal(3);
});
pm.test("Response time < 500ms", function () {
pm.expect(pm.response.responseTime).to.be.below(500);
});
pm.test("No PHI in response headers", function () {
pm.response.headers.each((header) => {
pm.expect(header.value).to.not.include("medication");
pm.expect(header.value).to.not.include("patient");
});
});
5.3. Service Integration Tests¶
// iOS: Test de sincronizacion completa
class SyncServiceIntegrationTests: XCTestCase {
var syncService: SyncService!
var localDB: RealmDatabase!
var mockServer: MockSyncServer!
override func setUp() async throws {
localDB = try RealmDatabase.inMemory()
mockServer = MockSyncServer()
syncService = SyncService(
localDB: localDB,
apiClient: mockServer.client
)
}
func test_fullSync_uploadsLocalChanges_downloadsRemote() async throws {
// Given: Local changes
let localMed = Medication.fixture(syncStatus: .pending)
try localDB.save(localMed)
// Given: Remote changes
mockServer.addRemoteBlob(Blob.fixture(entityType: .medication))
// When
try await syncService.performFullSync()
// Then: Local uploaded
XCTAssertEqual(mockServer.uploadedBlobs.count, 1)
// Then: Remote downloaded
let localMeds = try localDB.fetchAll(Medication.self)
XCTAssertEqual(localMeds.count, 2) // Original + downloaded
}
}
6. End-to-End Tests¶
6.1. Critical User Journeys¶
| Journey | Prioridad | Frecuencia |
|---|---|---|
| Registro completo | P0 | Cada PR |
| Agregar medicamento | P0 | Cada PR |
| Recibir alerta y confirmar toma | P0 | Cada PR |
| Configurar escalamiento a cuidador | P1 | Diario |
| Activar alerta de emergencia | P1 | Diario |
| Sync offline -> online | P1 | Diario |
| Ver historial de adherencia | P2 | Semanal |
6.2. iOS UI Tests¶
// XCUITest: Flujo de agregar medicamento
class AddMedicationE2ETests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--reset-state"]
app.launch()
}
func test_addMedication_fullFlow() {
// Navigate to add medication
app.buttons["add_medication_button"].tap()
// Fill form
let nameField = app.textFields["medication_name_field"]
nameField.tap()
nameField.typeText("Metformina")
let dosageField = app.textFields["medication_dosage_field"]
dosageField.tap()
dosageField.typeText("850mg")
// Select frequency
app.buttons["frequency_selector"].tap()
app.buttons["frequency_twice_daily"].tap()
// Set times
app.buttons["time_picker_1"].tap()
// ... adjust time picker
// Save
app.buttons["save_medication_button"].tap()
// Verify success
XCTAssertTrue(app.staticTexts["Metformina"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["850mg"].exists)
}
func test_addMedication_offline_syncsWhenOnline() {
// Disable network
app.launchArguments.append("--offline-mode")
app.launch()
// Add medication offline
addMedicationViaUI(name: "Lisinopril", dosage: "10mg")
// Verify local save
XCTAssertTrue(app.staticTexts["Lisinopril"].exists)
XCTAssertTrue(app.staticTexts["Pending sync"].exists)
// Re-enable network
app.terminate()
app.launchArguments.removeAll { $0 == "--offline-mode" }
app.launch()
// Wait for sync
let syncIndicator = app.staticTexts["Synced"]
XCTAssertTrue(syncIndicator.waitForExistence(timeout: 10))
}
}
6.3. Android UI Tests¶
// Espresso: Flujo de alerta de medicamento
@RunWith(AndroidJUnit4::class)
@LargeTest
class MedicationAlertE2ETest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@get:Rule
val notificationPermissionRule = GrantPermissionRule.grant(
Manifest.permission.POST_NOTIFICATIONS
)
@Test
fun alertFlow_userConfirmsTake_updatesHistory() {
// Given: Medication with scheduled alert
setupTestMedication(name = "Metformina", scheduledTime = "08:00")
// When: Alert triggers (simulated)
triggerAlertNotification(medicationName = "Metformina")
// Then: Notification appears
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.openNotification()
// User taps "Tomado"
device.findObject(UiSelector().text("Tomado")).click()
// Verify: History updated
onView(withId(R.id.nav_history)).perform(click())
onView(withText("Metformina")).check(matches(isDisplayed()))
onView(withText("Tomado")).check(matches(isDisplayed()))
}
}
7. Security Tests¶
7.1. Zero-Knowledge Tests¶
// Test: Servidor no puede leer contenido de blobs
class ZeroKnowledgeTests: XCTestCase {
func test_serverCannotReadBlobContent() async throws {
// Given: Encrypted medication blob
let medication = Medication.fixture(name: "Metformina Secreta")
let encryptedBlob = try EncryptionService.encrypt(medication)
// When: Upload to server
let response = try await apiClient.uploadBlob(encryptedBlob)
// Then: Server response doesn't contain decrypted content
XCTAssertNil(response.body.range(of: "Metformina"))
XCTAssertNil(response.body.range(of: "Secreta"))
// Then: Server only sees opaque blob
let serverBlob = try await apiClient.getBlob(encryptedBlob.id)
XCTAssertEqual(serverBlob.encrypted_data, encryptedBlob.encrypted_data)
}
func test_serverLogsDoNotContainPHI() async throws {
// Given: Request with PHI in encrypted blob
let blob = createBlobWithPHI()
// When: Make request
_ = try await apiClient.uploadBlob(blob)
// Then: Fetch server logs (test environment)
let logs = try await fetchServerLogs()
// Then: No PHI in logs
XCTAssertFalse(logs.contains("Metformina"))
XCTAssertFalse(logs.contains("Juan Perez"))
XCTAssertFalse(logs.contains("diabetes"))
}
}
7.2. Cifrado E2E Tests¶
// Test: Roundtrip de cifrado
class EncryptionTests: XCTestCase {
func test_encryptDecrypt_roundtrip() throws {
// Given
let original = "Metformina 850mg - Tomar con desayuno"
let key = EncryptionService.generateKey()
// When
let encrypted = try EncryptionService.encrypt(original, with: key)
let decrypted = try EncryptionService.decrypt(encrypted, with: key)
// Then
XCTAssertEqual(original, decrypted)
XCTAssertNotEqual(original, String(data: encrypted, encoding: .utf8))
}
func test_differentNoncePerOperation() throws {
// Given
let data = "Same data"
let key = EncryptionService.generateKey()
// When
let encrypted1 = try EncryptionService.encrypt(data, with: key)
let encrypted2 = try EncryptionService.encrypt(data, with: key)
// Then: Different ciphertext due to different nonce
XCTAssertNotEqual(encrypted1, encrypted2)
}
func test_wrongKey_failsDecryption() throws {
// Given
let data = "Secret medication"
let correctKey = EncryptionService.generateKey()
let wrongKey = EncryptionService.generateKey()
let encrypted = try EncryptionService.encrypt(data, with: correctKey)
// When/Then
XCTAssertThrowsError(
try EncryptionService.decrypt(encrypted, with: wrongKey)
) { error in
XCTAssertEqual(error as? EncryptionError, .decryptionFailed)
}
}
func test_keyDerivation_consistentWithPassword() throws {
// Given
let password = "SecurePassword123!"
let salt = EncryptionService.generateSalt()
// When
let key1 = try EncryptionService.deriveKey(from: password, salt: salt)
let key2 = try EncryptionService.deriveKey(from: password, salt: salt)
// Then
XCTAssertEqual(key1, key2)
}
}
7.3. RLS Policy Tests¶
-- Test completo de RLS policies
BEGIN;
SELECT plan(10);
-- Setup
INSERT INTO srv_users VALUES
('user-a', 'fb-a', 'PI', 'pro'),
('user-b', 'fb-b', 'PI', 'free'),
('caregiver-c', 'fb-c', 'CS', 'free');
INSERT INTO srv_user_relations VALUES
('rel-1', 'user-a', 'caregiver-c', 'caregiver_solicited', 'active', '{"can_view_medications": true}');
INSERT INTO srv_encrypted_blobs VALUES
('blob-a1', 'user-a', 'medication', 'data-a1'),
('blob-b1', 'user-b', 'medication', 'data-b1');
-- Test: User A ve sus propios blobs
SET app.firebase_uid = 'fb-a';
SELECT is(
(SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-a'),
1::bigint,
'User A can see own blobs'
);
-- Test: User A NO ve blobs de User B
SELECT is(
(SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-b'),
0::bigint,
'User A cannot see User B blobs'
);
-- Test: Caregiver C puede ver blobs de User A (por relacion)
SET app.firebase_uid = 'fb-c';
SELECT is(
(SELECT COUNT(*) FROM srv_encrypted_blobs
WHERE user_id = 'user-a'
AND EXISTS (
SELECT 1 FROM srv_user_relations
WHERE patient_id = 'user-a'
AND caregiver_id = 'caregiver-c'
AND status = 'active'
)),
1::bigint,
'Caregiver C can see User A blobs via relation'
);
-- Test: Caregiver C NO ve blobs de User B (sin relacion)
SELECT is(
(SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-b'),
0::bigint,
'Caregiver C cannot see User B blobs (no relation)'
);
SELECT * FROM finish();
ROLLBACK;
7.4. SAST y DAST¶
7.4.1. Security Scanning Tools Configuration (DV2-P3)¶
Agregado: TEST-BAJO-002 - Configuracion completa de herramientas de seguridad automatizadas.
7.4.1.1. SAST (Static Application Security Testing)¶
Herramientas Configuradas:
| Herramienta | Lenguaje | Integracion | Severidad Minima | Frecuencia |
|---|---|---|---|---|
| Snyk | Todos | GitHub Actions | High | Cada PR |
| SonarQube | Todos | PR checks | Medium | Cada PR |
| SwiftLint + Security | Swift | Xcode + CI | Warning | Cada build |
| Detekt + Security | Kotlin | Gradle + CI | Warning | Cada build |
| ESLint + Security | JavaScript/Node | CI | Medium | Cada PR |
| Semgrep | Todos | CI | High | Cada PR |
| GitGuardian | Secrets | Git hooks + CI | Any | Cada commit |
7.4.1.2. Snyk Configuration¶
# .snyk (DV2-P3)
version: v1.22.0
# Language-specific settings
language-settings:
javascript:
severity-threshold: high
swift:
severity-threshold: high
kotlin:
severity-threshold: high
# Exclude paths
exclude:
global:
- node_modules/**
- build/**
- test-fixtures/**
# Patch settings
patch:
auto-apply: false # Manual review required
# .github/workflows/snyk-security.yml (DV2-P3)
name: Snyk Security Scan
on:
push:
branches: [main, develop]
pull_request:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM
jobs:
snyk-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high --all-projects
- name: Check for Critical/High vulnerabilities
run: |
CRITICAL=$(snyk test --json | jq '[.vulnerabilities[] | select(.severity == "critical")] | length')
HIGH=$(snyk test --json | jq '[.vulnerabilities[] | select(.severity == "high")] | length')
echo "Critical vulnerabilities: $CRITICAL"
echo "High vulnerabilities: $HIGH"
if [ "$CRITICAL" -gt 0 ]; then
echo "::error::$CRITICAL critical vulnerabilities found"
exit 1
fi
7.4.1.3. SonarQube Configuration¶
# sonar-project.properties (DV2-P3)
sonar.projectKey=medtime-app
sonar.projectName=MedTime
sonar.projectVersion=1.0.0
# Source directories
sonar.sources=ios/MedTime,android/app/src/main,server/functions
sonar.tests=ios/MedTimeTests,android/app/src/test
# Quality Gate thresholds
sonar.qualitygate.wait=true
sonar.security.hotspots.threshold=0
sonar.security.rating=A
sonar.reliability.rating=A
# .github/workflows/sonarqube.yml (DV2-P3)
name: SonarQube Analysis
on:
push:
branches: [main, develop]
pull_request:
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: SonarQube Quality Gate
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
7.4.1.4. SwiftLint Security Rules¶
# .swiftlint.yml (Security Rules - DV2-P3)
opt_in_rules:
- force_unwrapping
- legacy_random
- legacy_hashing
- weak_delegate
custom_rules:
no_hardcoded_secrets:
name: "No Hardcoded Secrets"
regex: '(password|secret|api[_-]?key|token)\s*=\s*"[^"]+"'
message: "Hardcoded secrets detected. Use secure storage."
severity: error
no_phi_in_logs:
name: "No PHI in Logs"
regex: 'log.*\b(medication|patient|prescription)\b'
message: "Potential PHI in logs. Ensure data is encrypted."
severity: warning
7.4.1.5. Detekt Security Rules¶
# detekt-config.yml (Security Rules - DV2-P3)
security:
active: true
HardcodedPassword:
active: true
InsecureCryptoAlgorithm:
active: true
WeakRandom:
active: true
severity: error
custom-rules:
NoHardcodedSecrets:
active: true
pattern: '(password|secret|apiKey|token)\s*=\s*"[^"]+"'
severity: error
NoPhiInLogs:
active: true
pattern: 'log.*\b(medication|patient|prescription)\b'
severity: warning
7.4.1.6. Semgrep Configuration¶
# .semgrep.yml (DV2-P3)
rules:
- id: hardcoded-secret
patterns:
- pattern-either:
- pattern: password = "..."
- pattern: api_key = "..."
- pattern: secret = "..."
message: Hardcoded secret detected
severity: ERROR
languages: [swift, kotlin, javascript]
- id: weak-crypto
patterns:
- pattern-either:
- pattern: MD5(...)
- pattern: SHA1(...)
message: Weak cryptographic algorithm. Use SHA-256 or better.
severity: ERROR
languages: [swift, kotlin, javascript]
- id: phi-in-logs
patterns:
- pattern: log($PHI)
- metavariable-regex:
metavariable: $PHI
regex: .*(medication|patient).*
message: Potential PHI in logs
severity: WARNING
languages: [swift, kotlin, javascript]
7.4.1.7. GitGuardian Configuration¶
# .gitguardian.yml (DV2-P3)
version: 2
paths-ignore:
- "**/*.md"
- "**/test-fixtures/**"
minimum-severity: low
detectors:
- name: "AWS"
enabled: true
- name: "Generic Password"
enabled: true
- name: "API Keys"
enabled: true
exit-zero: false # Fail on secrets found
7.4.1.8. DAST (Dynamic Application Security Testing)¶
Herramientas Configuradas:
| Herramienta | Tipo | Frecuencia | Severidad Minima |
|---|---|---|---|
| OWASP ZAP | API scanning | Semanal | Medium |
| MobSF | Mobile app scanning | Por release | High |
| Burp Suite | Manual testing | Trimestral | High |
| Nuclei | Vulnerability scanning | Semanal | High |
7.4.1.9. OWASP ZAP Configuration¶
# .github/workflows/owasp-zap.yml (DV2-P3)
name: OWASP ZAP Scan
on:
schedule:
- cron: '0 3 * * 1' # Weekly on Monday
workflow_dispatch:
jobs:
zap-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start test environment
run: docker-compose -f docker-compose.test.yml up -d
- name: ZAP API Scan
uses: zaproxy/action-api-scan@v0.4.0
with:
target: 'http://localhost:3000/api-definition.yaml'
rules_file_name: 'zap-rules.tsv'
- name: Check for High/Critical findings
run: |
CRITICAL=$(cat report_json.json | jq '[.site[].alerts[] | select(.riskcode == "3")] | length')
if [ "$CRITICAL" -gt 0 ]; then
echo "::error::$CRITICAL critical vulnerabilities found"
exit 1
fi
7.4.1.10. MobSF Configuration¶
# .github/workflows/mobsf-scan.yml (DV2-P3)
name: MobSF Mobile Security Scan
on:
release:
types: [published]
workflow_dispatch:
jobs:
mobsf-ios:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build iOS .ipa
run: xcodebuild archive -scheme MedTime ...
- name: MobSF Scan
uses: fundacaocerti/mobsf-action@v1.3
with:
INPUT_FILE_NAME: 'MedTime.ipa'
SCAN_TYPE: 'ipa'
MOBSF_URL: ${{ secrets.MOBSF_URL }}
MOBSF_API_KEY: ${{ secrets.MOBSF_API_KEY }}
- name: Check Security Score
run: |
SCORE=$(cat mobsf-report.json | jq '.security_score')
if (( $(echo "$SCORE < 70" | bc -l) )); then
echo "::error::Security score too low: $SCORE"
exit 1
fi
7.4.1.11. Security Scanning Metrics¶
| Metric | Target | Alert Threshold |
|---|---|---|
| Critical vulnerabilities | 0 | Any found |
| High vulnerabilities | 0 | > 0 |
| Medium vulnerabilities | < 5 | > 10 |
| MobSF Security Score | > 80 | < 70 |
| SonarQube Security Rating | A | B or lower |
| Secrets detected | 0 | Any found |
7.5. Penetration Testing¶
| Tipo | Frecuencia | Scope |
|---|---|---|
| Automated | Continuo (CI) | OWASP Top 10 |
| Manual | Trimestral | Full application |
| Red Team | Anual | Infrastructure + App |
Checklist de Penetration Test:
- Authentication bypass attempts
- RLS circumvention attempts
- Blob decryption without key
- SQL injection on server
- API rate limiting bypass
- JWT manipulation
- MITM attack resistance
8. Performance Tests¶
8.1. Performance Baselines (DV2-P2)¶
Agregado: TEST-MEDIO-002 - Baselines establecidos para todas las operaciones criticas.
8.1.1. Baseline Methodology¶
flowchart LR
subgraph Establecimiento["BASELINE ESTABLISHMENT"]
M[Medicion Inicial] --> A[Analisis Estadistico]
A --> B[Baseline + Tolerancia]
B --> R[Registro en CI]
end
subgraph Monitoreo["REGRESSION DETECTION"]
R --> C[Comparacion Continua]
C --> |Regresion > 10%| AL[Alerta]
C --> |OK| PASS[Pass]
end
8.1.2. Baselines por Operacion¶
| Categoria | Operacion | Baseline P50 | Baseline P99 | Tolerancia | Critico |
|---|---|---|---|---|---|
| Startup | Cold launch iOS | 1.5s | 2.5s | +15% | +50% |
| Startup | Cold launch Android | 1.8s | 2.8s | +15% | +50% |
| Startup | Warm launch | 400ms | 800ms | +20% | +100% |
| Crypto | Encrypt blob (1KB) | 35ms | 80ms | +10% | +100% |
| Crypto | Decrypt blob (1KB) | 30ms | 70ms | +10% | +100% |
| Crypto | Key derivation (Argon2) | 200ms | 350ms | +10% | +50% |
| DB | Insert medication | 15ms | 40ms | +20% | +150% |
| DB | Query medications (100) | 80ms | 150ms | +15% | +100% |
| DB | Full-text search | 50ms | 120ms | +20% | +150% |
| Sync | Push 10 blobs | 500ms | 1.2s | +20% | +100% |
| Sync | Pull 50 blobs | 800ms | 2s | +20% | +100% |
| Sync | Conflict resolution | 100ms | 250ms | +15% | +100% |
| UI | List render (100 items) | 16ms | 32ms | +20% | +100% |
| UI | Form validation | 5ms | 15ms | +25% | +200% |
| Notification | Schedule alert | 8ms | 25ms | +25% | +200% |
| Notification | Cancel alert | 5ms | 15ms | +25% | +200% |
8.1.3. Baseline Testing Implementation¶
// iOS: Performance Baseline Tests
class PerformanceBaselineTests: XCTestCase {
// MARK: - Baselines (DV2-P2)
private let baselines: [String: (p50: Double, p99: Double, tolerance: Double)] = [
"encrypt_1kb": (p50: 0.035, p99: 0.080, tolerance: 0.10),
"decrypt_1kb": (p50: 0.030, p99: 0.070, tolerance: 0.10),
"db_insert": (p50: 0.015, p99: 0.040, tolerance: 0.20),
"db_query_100": (p50: 0.080, p99: 0.150, tolerance: 0.15)
]
func test_encrypt_baseline() {
let baseline = baselines["encrypt_1kb"]!
let data = Data(repeating: 0x42, count: 1024)
let metrics = measureWithMetrics([XCTClockMetric()]) {
_ = try? EncryptionService.shared.encrypt(data)
}
// Verify against baseline
let avgTime = metrics.averageValue(for: XCTClockMetric.self)
XCTAssertLessThan(
avgTime,
baseline.p50 * (1 + baseline.tolerance),
"Encryption exceeded baseline by more than \(baseline.tolerance * 100)%"
)
}
func test_db_query_baseline() {
let baseline = baselines["db_query_100"]!
// Setup: Insert 100 medications
try? setupTestMedications(count: 100)
let metrics = measureWithMetrics([XCTClockMetric()]) {
_ = try? repository.fetchAll()
}
XCTAssertLessThan(
metrics.averageValue(for: XCTClockMetric.self),
baseline.p50 * (1 + baseline.tolerance)
)
}
}
// Android: Performance Baseline Tests
@RunWith(AndroidJUnit4::class)
class PerformanceBaselineTest {
companion object {
// Baselines (DV2-P2)
val BASELINES = mapOf(
"encrypt_1kb" to Baseline(p50 = 35.0, p99 = 80.0, tolerance = 0.10),
"decrypt_1kb" to Baseline(p50 = 30.0, p99 = 70.0, tolerance = 0.10),
"db_insert" to Baseline(p50 = 15.0, p99 = 40.0, tolerance = 0.20),
"db_query_100" to Baseline(p50 = 80.0, p99 = 150.0, tolerance = 0.15)
)
}
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun encrypt_baseline() {
val baseline = BASELINES["encrypt_1kb"]!!
val data = ByteArray(1024) { 0x42 }
benchmarkRule.measureRepeated {
runWithTimingDisabled { /* setup */ }
encryptionService.encrypt(data)
}
val avgMs = benchmarkRule.getMetrics().metrics["timeNs"]!! / 1_000_000.0
assertThat(avgMs).isLessThan(baseline.p50 * (1 + baseline.tolerance))
}
data class Baseline(val p50: Double, val p99: Double, val tolerance: Double)
}
8.1.4. CI Regression Detection¶
# .github/workflows/performance-baselines.yml
name: Performance Baseline Check
on:
pull_request:
paths:
- 'ios/**'
- 'android/**'
jobs:
ios-performance:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Run Performance Tests
run: |
xcodebuild test \
-scheme MedTime-Performance \
-destination 'platform=iOS Simulator,name=iPhone 15'
- name: Compare with Baselines
run: |
python scripts/compare_baselines.py \
--current test-results/performance.json \
--baselines baselines/ios.json \
--threshold 0.10
android-performance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Benchmark Tests
run: ./gradlew :benchmark:connectedCheck
- name: Compare with Baselines
run: |
python scripts/compare_baselines.py \
--current benchmark/results.json \
--baselines baselines/android.json \
--threshold 0.10
8.2. Benchmarks Cliente¶
| Operacion | Target | Critico | Herramienta |
|---|---|---|---|
| App launch (cold) | < 2s | < 3s | Instruments/Android Profiler |
| App launch (warm) | < 500ms | < 1s | Instruments |
| Load medication list (100 items) | < 100ms | < 200ms | XCTest measure |
| Encrypt medication blob | < 50ms | < 100ms | Unit test |
| Decrypt medication blob | < 50ms | < 100ms | Unit test |
| Schedule notification | < 10ms | < 50ms | Unit test |
| Local DB query | < 20ms | < 50ms | Integration test |
// iOS: Performance test de cifrado
func test_encryptMedication_performance() {
let medication = Medication.fixture()
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
_ = try? EncryptionService.encrypt(medication)
}
}
8.3. Benchmarks Servidor¶
| Endpoint | Target P50 | Target P99 | Max |
|---|---|---|---|
| POST /auth/login | 200ms | 500ms | 1s |
| GET /sync/pull | 300ms | 800ms | 2s |
| POST /sync/push | 300ms | 800ms | 2s |
| GET /catalog/drugs (search) | 100ms | 300ms | 500ms |
| POST /alerts/emergency | 100ms | 200ms | 500ms |
8.4. Load Testing¶
8.4.1. Load Testing Scenarios (DV2-P3)¶
Agregado: TEST-BAJO-003 - Scenarios realistas de load testing para todos los endpoints criticos.
8.4.1.1. Load Test Strategy¶
flowchart TD
subgraph Scenarios["LOAD TEST SCENARIOS"]
SMOKE[Smoke Test<br/>1-5 users]
LOAD[Load Test<br/>Expected load]
STRESS[Stress Test<br/>Beyond capacity]
SPIKE[Spike Test<br/>Sudden surge]
SOAK[Soak Test<br/>Extended duration]
end
SMOKE --> LOAD
LOAD --> STRESS
LOAD --> SPIKE
LOAD --> SOAK
style SMOKE fill:#99ff99
style LOAD fill:#99ccff
style STRESS fill:#ffcc99
style SPIKE fill:#ff9999
style SOAK fill:#cc99ff
8.4.1.2. Scenario 1: Smoke Test (Sanity Check)¶
Objetivo: Verificar que el sistema funciona bajo carga minima.
// load-tests/smoke-test.js (DV2-P3)
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const errorRate = new Rate('errors');
export const options = {
vus: 1, // 1 usuario virtual
duration: '1m', // 1 minuto
thresholds: {
http_req_duration: ['p(99)<500'], // 99% < 500ms
errors: ['rate<0.01'], // < 1% errors
},
};
export default function () {
const BASE_URL = __ENV.BASE_URL || 'https://api.medtime.app';
const token = getAuthToken();
// Test critical endpoints
testAuth(BASE_URL);
testSyncPull(BASE_URL, token);
testSyncPush(BASE_URL, token);
testCatalogSearch(BASE_URL, token);
sleep(1);
}
function testAuth(baseUrl) {
const res = http.post(`${baseUrl}/auth/login`, JSON.stringify({
email: 'test@medtime.app',
password: 'test-password'
}), {
headers: { 'Content-Type': 'application/json' }
});
const success = check(res, {
'auth: status 200': (r) => r.status === 200,
'auth: has token': (r) => r.json('access_token') !== null,
'auth: time < 500ms': (r) => r.timings.duration < 500,
});
errorRate.add(!success);
}
8.4.1.3. Scenario 2: Load Test (Expected Load)¶
Objetivo: Simular carga esperada durante uso normal.
Assumptions:
- 10,000 usuarios activos diarios
- 20% usan la app simultaneamente en pico (2,000 usuarios)
- Cada usuario hace 10 operaciones por sesion
- Sesion promedio: 5 minutos
// load-tests/load-test.js (DV2-P3)
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const syncPullDuration = new Trend('sync_pull_duration');
const syncPushDuration = new Trend('sync_push_duration');
const errorCounter = new Counter('errors');
export const options = {
stages: [
{ duration: '5m', target: 500 }, // Ramp up to 500 VUs
{ duration: '10m', target: 1000 }, // Ramp to 1000 VUs
{ duration: '15m', target: 2000 }, // Peak load: 2000 VUs
{ duration: '10m', target: 1000 }, // Ramp down to 1000
{ duration: '5m', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<1000', 'p(99)<2000'],
http_req_failed: ['rate<0.01'],
sync_pull_duration: ['p(95)<800'],
sync_push_duration: ['p(95)<800'],
errors: ['count<100'],
},
};
export default function () {
const BASE_URL = __ENV.BASE_URL;
const userId = `user-${__VU}`;
const token = authenticateUser(userId);
// Realistic user session
group('User Session', function() {
// 1. Pull latest data
group('Sync Pull', function() {
const startTime = Date.now();
const res = http.get(`${BASE_URL}/sync/pull?since=${getLastSyncTime()}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const success = check(res, {
'pull: status 200': (r) => r.status === 200,
'pull: has changes': (r) => r.json('changes') !== null,
});
syncPullDuration.add(Date.now() - startTime);
if (!success) errorCounter.add(1);
});
sleep(2);
// 2. View medications
group('View Medications', function() {
const res = http.get(`${BASE_URL}/medications`, {
headers: { 'Authorization': `Bearer ${token}` }
});
check(res, {
'medications: status 200': (r) => r.status === 200,
});
});
sleep(5);
// 3. Add/Update medication (20% of users)
if (Math.random() < 0.2) {
group('Update Medication', function() {
const startTime = Date.now();
const res = http.post(`${BASE_URL}/sync/push`, JSON.stringify({
changes: [generateMedicationBlob()]
}), {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const success = check(res, {
'push: status 200': (r) => r.status === 200,
'push: processed': (r) => r.json('processed') > 0,
});
syncPushDuration.add(Date.now() - startTime);
if (!success) errorCounter.add(1);
});
sleep(3);
}
// 4. Search drug catalog (30% of users)
if (Math.random() < 0.3) {
group('Search Catalog', function() {
const res = http.get(`${BASE_URL}/catalog/drugs?q=metformin`, {
headers: { 'Authorization': `Bearer ${token}` }
});
check(res, {
'catalog: status 200': (r) => r.status === 200,
'catalog: has results': (r) => r.json('results').length > 0,
});
});
sleep(2);
}
// 5. View alerts (50% of users)
if (Math.random() < 0.5) {
group('View Alerts', function() {
const res = http.get(`${BASE_URL}/alerts`, {
headers: { 'Authorization': `Bearer ${token}` }
});
check(res, {
'alerts: status 200': (r) => r.status === 200,
});
});
sleep(3);
}
});
sleep(Math.random() * 10 + 5); // Random think time 5-15s
}
8.4.1.4. Scenario 3: Stress Test (Beyond Capacity)¶
Objetivo: Encontrar el punto de quiebre del sistema.
// load-tests/stress-test.js (DV2-P3)
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Warm up
{ duration: '5m', target: 1000 }, // Expected load
{ duration: '5m', target: 2000 }, // Beyond expected
{ duration: '5m', target: 3000 }, // Stress
{ duration: '5m', target: 4000 }, // More stress
{ duration: '5m', target: 5000 }, // Maximum stress
{ duration: '5m', target: 0 }, // Recovery
],
thresholds: {
http_req_duration: ['p(99)<5000'], // Allow higher latency
http_req_failed: ['rate<0.05'], // Allow 5% errors
},
};
export default function () {
// Same as load test but observe degradation
const BASE_URL = __ENV.BASE_URL;
const token = getAuthToken();
const res = http.get(`${BASE_URL}/sync/pull`, {
headers: { 'Authorization': `Bearer ${token}` }
});
check(res, {
'status not 5xx': (r) => r.status < 500, // No server crashes
'response time tracked': (r) => r.timings.duration > 0,
});
sleep(1);
}
8.4.1.5. Scenario 4: Spike Test (Sudden Traffic Surge)¶
Objetivo: Probar comportamiento ante picos repentinos (notificacion masiva, lanzamiento).
// load-tests/spike-test.js (DV2-P3)
export const options = {
stages: [
{ duration: '1m', target: 100 }, // Normal load
{ duration: '30s', target: 3000 }, // Sudden spike!
{ duration: '2m', target: 3000 }, // Hold spike
{ duration: '1m', target: 100 }, // Return to normal
{ duration: '2m', target: 100 }, // Sustained normal
],
thresholds: {
http_req_duration: ['p(99)<3000'], // Some degradation OK
http_req_failed: ['rate<0.02'], // < 2% errors
},
};
export default function () {
const BASE_URL = __ENV.BASE_URL;
const token = getAuthToken();
// Simulate morning alert rush (8 AM)
const res = http.post(`${BASE_URL}/alerts/confirm`, JSON.stringify({
alert_id: generateAlertId(),
status: 'taken'
}), {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
check(res, {
'alert confirmed': (r) => r.status === 200,
'no timeout': (r) => r.timings.duration < 5000,
});
sleep(0.5); // Shorter sleep during spike
}
8.4.1.6. Scenario 5: Soak Test (Endurance)¶
Objetivo: Detectar memory leaks y degradacion en el tiempo.
// load-tests/soak-test.js (DV2-P3)
export const options = {
stages: [
{ duration: '5m', target: 500 }, // Ramp up
{ duration: '3h', target: 500 }, // Sustained load: 3 hours
{ duration: '5m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(99)<2000'], // No degradation over time
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const BASE_URL = __ENV.BASE_URL;
const token = getAuthToken();
// Standard user operations
http.get(`${BASE_URL}/sync/pull`, {
headers: { 'Authorization': `Bearer ${token}` }
});
sleep(5);
http.post(`${BASE_URL}/sync/push`, JSON.stringify({
changes: [generateTestBlob()]
}), {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
sleep(10);
}
8.4.1.7. Scenario 6: Concurrent Medication Updates (Race Conditions)¶
Objetivo: Probar conflict resolution bajo alta concurrencia.
// load-tests/concurrent-updates-test.js (DV2-P3)
export const options = {
scenarios: {
concurrent_updates: {
executor: 'constant-vus',
vus: 50,
duration: '5m',
},
},
thresholds: {
'conflicts_detected': ['rate>0.1'], // Expect conflicts
'conflicts_resolved': ['rate>0.99'], // 99% resolved correctly
},
};
export default function () {
const BASE_URL = __ENV.BASE_URL;
const token = getAuthToken();
const medicationId = 'shared-med-123'; // Same medication updated by all VUs
// Concurrent update to same medication
const res = http.post(`${BASE_URL}/sync/push`, JSON.stringify({
changes: [{
blob_id: medicationId,
entity_type: 'medication',
operation: 'upsert',
encrypted_data: generateEncryptedData(),
version: Math.floor(Math.random() * 10) + 1 // Random version
}]
}), {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
check(res, {
'update processed': (r) => r.status === 200 || r.status === 409,
'conflict handled': (r) => {
if (r.status === 409) {
// Conflict detected and returned
return r.json('conflicts').length > 0;
}
return true;
},
});
sleep(0.5);
}
8.4.1.8. Load Test Execution Matrix¶
| Scenario | Frequency | Duration | Target VUs | Threshold P99 | Alerts On |
|---|---|---|---|---|---|
| Smoke | Each PR | 1 min | 1-5 | 500ms | Failure |
| Load | Nightly | 45 min | 2,000 | 2s | >5% errors |
| Stress | Weekly | 35 min | 5,000 | 5s | Server crash |
| Spike | Weekly | 6.5 min | 3,000 | 3s | >2% errors |
| Soak | Weekly | 3h 10min | 500 | 2s | Memory leak |
| Concurrent | Daily | 5 min | 50 | 1s | Data corruption |
8.4.1.9. Load Test CI Integration¶
# .github/workflows/load-tests.yml (DV2-P3)
name: Load Tests
on:
schedule:
- cron: '0 2 * * *' # Nightly at 2 AM
workflow_dispatch:
inputs:
scenario:
description: 'Test scenario'
required: true
type: choice
options:
- smoke
- load
- stress
- spike
- soak
- concurrent
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup k6
run: |
curl https://github.com/grafana/k6/releases/download/v0.48.0/k6-v0.48.0-linux-amd64.tar.gz -L | tar xvz
sudo mv k6-v0.48.0-linux-amd64/k6 /usr/local/bin/
- name: Run Load Test
env:
BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL }}
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
run: |
SCENARIO=${{ github.event.inputs.scenario || 'load' }}
k6 run --out cloud load-tests/${SCENARIO}-test.js
- name: Check Results
run: |
# Parse k6 output
FAILURES=$(cat k6-results.json | jq '.metrics.http_req_failed.values.rate')
P99=$(cat k6-results.json | jq '.metrics.http_req_duration.values["p(99)"]')
echo "Failure rate: $FAILURES"
echo "P99 latency: ${P99}ms"
if (( $(echo "$FAILURES > 0.05" | bc -l) )); then
echo "::error::Failure rate too high: $FAILURES"
exit 1
fi
- name: Upload Results
uses: actions/upload-artifact@v3
if: always()
with:
name: load-test-results
path: |
k6-results.json
k6-summary.html
8.4.1.10. Load Test Monitoring Dashboard¶
// Grafana dashboard queries for k6 metrics
const k6Queries = {
responseTime: 'avg(k6_http_req_duration)',
throughput: 'rate(k6_http_reqs_total[5m])',
errorRate: 'rate(k6_http_req_failed[5m])',
activeUsers: 'k6_vus',
};
8.4.1.11. Expected Results & SLOs¶
| Metric | Target | Warning | Critical |
|---|---|---|---|
| P50 Latency | < 300ms | > 500ms | > 1s |
| P95 Latency | < 1s | > 1.5s | > 2s |
| P99 Latency | < 2s | > 3s | > 5s |
| Error Rate | < 0.1% | > 1% | > 5% |
| Throughput | > 100 req/s | < 50 req/s | < 20 req/s |
| Concurrent Users | 2,000 | 1,000 | 500 |
9. Offline-First Tests¶
9.1. Escenarios Offline¶
| Escenario | Test | Validacion |
|---|---|---|
| Sin conexion desde inicio | E2E | App funciona, datos locales |
| Conexion perdida mid-session | Integration | Operaciones continuan |
| Reconexion | Integration | Sync automatico |
| Offline por 7 dias | E2E | Alertas funcionan |
// Test: Alertas funcionan offline
func test_alertsFunctionOffline() async throws {
// Given: Disable network
NetworkSimulator.setOffline()
// Given: Scheduled medication alert
let alert = Alert.fixture(scheduledFor: Date().addingTimeInterval(5))
try alertService.schedule(alert)
// When: Wait for alert time
try await Task.sleep(nanoseconds: 6_000_000_000) // 6 seconds
// Then: Local notification triggered
let notifications = await notificationCenter.pendingNotifications()
XCTAssertTrue(notifications.contains { $0.id == alert.id })
}
9.2. Sync Conflict Tests¶
// Test: Resolucion de conflictos
func test_syncConflict_lastWriteWins() async throws {
// Given: Local version
let localMed = Medication.fixture(name: "Local Name", version: 2)
try localDB.save(localMed)
// Given: Server version (older)
let serverMed = Medication.fixture(id: localMed.id, name: "Server Name", version: 1)
mockServer.setRemoteData([serverMed])
// When: Sync
try await syncService.performSync()
// Then: Local wins (higher version)
let result = try localDB.fetch(Medication.self, id: localMed.id)
XCTAssertEqual(result?.name, "Local Name")
XCTAssertEqual(result?.version, 2)
}
func test_syncConflict_serverWins_whenNewer() async throws {
// Given: Local version (older)
let localMed = Medication.fixture(name: "Local Name", version: 1)
try localDB.save(localMed)
// Given: Server version (newer)
let serverMed = Medication.fixture(id: localMed.id, name: "Server Name", version: 3)
mockServer.setRemoteData([serverMed])
// When: Sync
try await syncService.performSync()
// Then: Server wins
let result = try localDB.fetch(Medication.self, id: localMed.id)
XCTAssertEqual(result?.name, "Server Name")
XCTAssertEqual(result?.version, 3)
}
10. Test Data Management¶
10.1. Fixtures y Factories¶
10.1.1. Fixture Library Completa (DV2-P2)¶
Agregado: TEST-MEDIO-003 - Fixtures para todas las entidades del sistema.
| Entidad | iOS Fixture | Android Fixture | Variantes |
|---|---|---|---|
| User | User.fixture() |
UserFixture.create() |
PI, CS, Admin |
| Medication | Medication.fixture() |
MedicationFixture.create() |
Active, Inactive, Expired |
| Alert | Alert.fixture() |
AlertFixture.create() |
Scheduled, Triggered, Snoozed |
| Prescription | Prescription.fixture() |
PrescriptionFixture.create() |
Active, Completed, Cancelled |
| EncryptedBlob | EncryptedBlob.fixture() |
EncryptedBlobFixture.create() |
All entity types |
| SyncChange | SyncChange.fixture() |
SyncChangeFixture.create() |
Upsert, Delete |
| Notification | Notification.fixture() |
NotificationFixture.create() |
Push, Local |
| UserRelation | UserRelation.fixture() |
UserRelationFixture.create() |
Caregiver, Family |
| AdherenceRecord | AdherenceRecord.fixture() |
AdherenceRecordFixture.create() |
Taken, Missed, Skipped |
| HealthEvent | HealthEvent.fixture() |
HealthEventFixture.create() |
Vital, Symptom, Note |
// iOS: Fixture Factory
extension Medication {
static func fixture(
id: UUID = UUID(),
name: String = "Test Medication",
dosage: String = "100mg",
frequency: Frequency = .daily,
syncStatus: SyncStatus = .synced,
version: Int = 1
) -> Medication {
Medication(
id: id,
name: name,
dosage: dosage,
frequency: frequency,
syncStatus: syncStatus,
version: version,
createdAt: Date(),
updatedAt: Date()
)
}
}
// Kotlin: Fixture Factory
object MedicationFixture {
fun create(
id: UUID = UUID.randomUUID(),
name: String = "Test Medication",
dosage: String = "100mg",
frequency: Frequency = Frequency.DAILY,
syncStatus: SyncStatus = SyncStatus.SYNCED,
version: Int = 1
) = Medication(
id = id,
name = name,
dosage = dosage,
frequency = frequency,
syncStatus = syncStatus,
version = version,
createdAt = Instant.now(),
updatedAt = Instant.now()
)
}
10.1.2. Fixtures Adicionales (DV2-P2)¶
// iOS: Fixtures completos para todas las entidades
// MARK: - User Fixtures
extension User {
static func fixture(
id: UUID = UUID(),
email: String = "test@medtime.app",
role: UserRole = .patientIndependent,
tier: SubscriptionTier = .free,
firebaseUid: String = "firebase-test-uid"
) -> User {
User(id: id, email: email, role: role, tier: tier, firebaseUid: firebaseUid)
}
static func caregiverFixture() -> User {
fixture(role: .caregiverSolicited, email: "caregiver@medtime.app")
}
}
// MARK: - Alert Fixtures
extension Alert {
static func fixture(
id: UUID = UUID(),
medicationId: UUID = UUID(),
scheduledTime: Date = Date().addingTimeInterval(3600),
status: AlertStatus = .scheduled,
snoozeCount: Int = 0
) -> Alert {
Alert(
id: id,
medicationId: medicationId,
scheduledTime: scheduledTime,
status: status,
snoozeCount: snoozeCount
)
}
static func triggeredFixture() -> Alert {
fixture(scheduledTime: Date().addingTimeInterval(-60), status: .triggered)
}
static func snoozedFixture() -> Alert {
fixture(status: .snoozed, snoozeCount: 1)
}
}
// MARK: - Prescription Fixtures
extension Prescription {
static func fixture(
id: UUID = UUID(),
doctorName: String = "Dr. Test",
rxNumber: String = "RX-123456",
medications: [UUID] = [],
status: PrescriptionStatus = .active,
expiresAt: Date = Date().addingTimeInterval(86400 * 30)
) -> Prescription {
Prescription(
id: id,
doctorName: doctorName,
rxNumber: rxNumber,
medications: medications,
status: status,
expiresAt: expiresAt
)
}
}
// MARK: - EncryptedBlob Fixtures
extension EncryptedBlob {
static func fixture(
id: UUID = UUID(),
entityType: EntityType = .medication,
encryptedData: Data = Data("encrypted-test-content".utf8),
checksum: String = "sha256-test-checksum",
version: Int = 1
) -> EncryptedBlob {
EncryptedBlob(
id: id,
entityType: entityType,
encryptedData: encryptedData,
checksum: checksum,
version: version
)
}
static func medicationBlob() -> EncryptedBlob {
fixture(entityType: .medication)
}
static func alertBlob() -> EncryptedBlob {
fixture(entityType: .alert)
}
}
// MARK: - SyncChange Fixtures
extension SyncChange {
static func fixture(
blobId: UUID = UUID(),
operation: SyncOperation = .upsert,
entityType: EntityType = .medication,
timestamp: Date = Date()
) -> SyncChange {
SyncChange(
blobId: blobId,
operation: operation,
entityType: entityType,
timestamp: timestamp
)
}
static func deleteFixture() -> SyncChange {
fixture(operation: .delete)
}
}
// MARK: - AdherenceRecord Fixtures
extension AdherenceRecord {
static func fixture(
id: UUID = UUID(),
medicationId: UUID = UUID(),
alertId: UUID = UUID(),
status: AdherenceStatus = .taken,
recordedAt: Date = Date()
) -> AdherenceRecord {
AdherenceRecord(
id: id,
medicationId: medicationId,
alertId: alertId,
status: status,
recordedAt: recordedAt
)
}
static func missedFixture() -> AdherenceRecord {
fixture(status: .missed)
}
static func skippedFixture() -> AdherenceRecord {
fixture(status: .skipped)
}
}
// MARK: - UserRelation Fixtures
extension UserRelation {
static func fixture(
id: UUID = UUID(),
patientId: UUID = UUID(),
caregiverId: UUID = UUID(),
type: RelationType = .caregiverSolicited,
status: RelationStatus = .active,
permissions: CaregiverPermissions = .default
) -> UserRelation {
UserRelation(
id: id,
patientId: patientId,
caregiverId: caregiverId,
type: type,
status: status,
permissions: permissions
)
}
}
// Android: Fixtures completos para todas las entidades
// User Fixtures
object UserFixture {
fun create(
id: UUID = UUID.randomUUID(),
email: String = "test@medtime.app",
role: UserRole = UserRole.PATIENT_INDEPENDENT,
tier: SubscriptionTier = SubscriptionTier.FREE,
firebaseUid: String = "firebase-test-uid"
) = User(id, email, role, tier, firebaseUid)
fun caregiver() = create(
role = UserRole.CAREGIVER_SOLICITED,
email = "caregiver@medtime.app"
)
}
// Alert Fixtures
object AlertFixture {
fun create(
id: UUID = UUID.randomUUID(),
medicationId: UUID = UUID.randomUUID(),
scheduledTime: Instant = Instant.now().plusSeconds(3600),
status: AlertStatus = AlertStatus.SCHEDULED,
snoozeCount: Int = 0
) = Alert(id, medicationId, scheduledTime, status, snoozeCount)
fun triggered() = create(
scheduledTime = Instant.now().minusSeconds(60),
status = AlertStatus.TRIGGERED
)
fun snoozed() = create(status = AlertStatus.SNOOZED, snoozeCount = 1)
}
// Prescription Fixtures
object PrescriptionFixture {
fun create(
id: UUID = UUID.randomUUID(),
doctorName: String = "Dr. Test",
rxNumber: String = "RX-123456",
medications: List<UUID> = emptyList(),
status: PrescriptionStatus = PrescriptionStatus.ACTIVE
) = Prescription(id, doctorName, rxNumber, medications, status)
}
// EncryptedBlob Fixtures
object EncryptedBlobFixture {
fun create(
id: UUID = UUID.randomUUID(),
entityType: EntityType = EntityType.MEDICATION,
encryptedData: ByteArray = "encrypted-test-content".toByteArray(),
checksum: String = "sha256-test-checksum",
version: Int = 1
) = EncryptedBlob(id, entityType, encryptedData, checksum, version)
fun medication() = create(entityType = EntityType.MEDICATION)
fun alert() = create(entityType = EntityType.ALERT)
}
// SyncChange Fixtures
object SyncChangeFixture {
fun create(
blobId: UUID = UUID.randomUUID(),
operation: SyncOperation = SyncOperation.UPSERT,
entityType: EntityType = EntityType.MEDICATION,
timestamp: Instant = Instant.now()
) = SyncChange(blobId, operation, entityType, timestamp)
fun delete() = create(operation = SyncOperation.DELETE)
}
// AdherenceRecord Fixtures
object AdherenceRecordFixture {
fun create(
id: UUID = UUID.randomUUID(),
medicationId: UUID = UUID.randomUUID(),
alertId: UUID = UUID.randomUUID(),
status: AdherenceStatus = AdherenceStatus.TAKEN,
recordedAt: Instant = Instant.now()
) = AdherenceRecord(id, medicationId, alertId, status, recordedAt)
fun missed() = create(status = AdherenceStatus.MISSED)
fun skipped() = create(status = AdherenceStatus.SKIPPED)
}
// UserRelation Fixtures
object UserRelationFixture {
fun create(
id: UUID = UUID.randomUUID(),
patientId: UUID = UUID.randomUUID(),
caregiverId: UUID = UUID.randomUUID(),
type: RelationType = RelationType.CAREGIVER_SOLICITED,
status: RelationStatus = RelationStatus.ACTIVE,
permissions: CaregiverPermissions = CaregiverPermissions.DEFAULT
) = UserRelation(id, patientId, caregiverId, type, status, permissions)
}
10.2. Data Sanitization¶
Reglas estrictas:
- NUNCA usar datos reales de pacientes
- NUNCA copiar PHI de produccion
- Usar generadores de datos falsos (Faker)
- Revisar fixtures antes de commit
// NO HACER:
let medication = Medication(name: "Juan Garcia - Metformina")
// SI HACER:
let medication = Medication.fixture(name: "Test User - Test Med")
11. CI/CD Integration¶
11.1. GitHub Actions Pipelines¶
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [main, 'TechSpec-*']
pull_request:
branches: [main]
jobs:
unit-tests-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Run iOS Unit Tests
run: |
xcodebuild test \
-scheme MedTime \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-enableCodeCoverage YES
- name: Upload Coverage
uses: codecov/codecov-action@v3
unit-tests-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Run Android Unit Tests
run: ./gradlew testDebugUnitTest
- name: Upload Coverage
uses: codecov/codecov-action@v3
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Run RLS Tests
run: |
psql -h localhost -U postgres -f tests/db/setup.sql
pg_prove tests/db/*.sql
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Snyk Security Scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
11.2. Quality Gates¶
| Gate | Criterio | Bloquea PR |
|---|---|---|
| Unit Test Coverage | >= 80% | Si |
| Integration Tests | 100% pass | Si |
| Security Scan | 0 High/Critical | Si |
| Performance Regression | < 10% | Warning |
| E2E Critical Paths | 100% pass | Si |
11.2.1. Quality Gates Configurados (DV2-P2)¶
Agregado: TEST-MEDIO-004 - Quality gates completos para CI/CD.
# .github/workflows/quality-gates.yml
name: Quality Gates
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
env:
COVERAGE_THRESHOLD: 80
SECURITY_MAX_HIGH: 0
SECURITY_MAX_CRITICAL: 0
PERF_REGRESSION_THRESHOLD: 10
jobs:
# Gate 1: Unit Test Coverage
unit-coverage:
name: "Gate: Unit Test Coverage >= 80%"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run iOS Unit Tests with Coverage
run: |
xcodebuild test \
-scheme MedTime \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
- name: Run Android Unit Tests with Coverage
run: ./gradlew testDebugUnitTestCoverage
- name: Check Coverage Threshold
run: |
IOS_COVERAGE=$(xcrun xccov view --report TestResults.xcresult --json | jq '.lineCoverage * 100')
ANDROID_COVERAGE=$(cat android/build/reports/coverage/debug/report.xml | grep -oP 'line-rate="\K[^"]+' | head -1 | awk '{print $1 * 100}')
echo "iOS Coverage: $IOS_COVERAGE%"
echo "Android Coverage: $ANDROID_COVERAGE%"
if (( $(echo "$IOS_COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then
echo "::error::iOS coverage ($IOS_COVERAGE%) below threshold ($COVERAGE_THRESHOLD%)"
exit 1
fi
if (( $(echo "$ANDROID_COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then
echo "::error::Android coverage ($ANDROID_COVERAGE%) below threshold ($COVERAGE_THRESHOLD%)"
exit 1
fi
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
# Gate 2: Integration Tests
integration-tests:
name: "Gate: Integration Tests 100% Pass"
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v4
- name: Run API Contract Tests
run: npm run test:contracts
- name: Run Database Tests (pgTAP)
run: |
psql -h localhost -U postgres -f tests/db/setup.sql
pg_prove --verbose tests/db/*.sql
- name: Run RLS Policy Tests
run: npm run test:rls
- name: Verify All Passed
run: |
if [ -f test-results/failures.json ]; then
FAILURES=$(cat test-results/failures.json | jq '.count')
if [ "$FAILURES" -gt 0 ]; then
echo "::error::$FAILURES integration tests failed"
exit 1
fi
fi
# Gate 3: Security Scan
security-scan:
name: "Gate: Security 0 High/Critical"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Snyk Security Scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'MedTime'
format: 'JSON'
failOnCVSS: 7
- name: Check Security Results
run: |
CRITICAL=$(cat dependency-check-report.json | jq '[.dependencies[].vulnerabilities[]? | select(.severity == "CRITICAL")] | length')
HIGH=$(cat dependency-check-report.json | jq '[.dependencies[].vulnerabilities[]? | select(.severity == "HIGH")] | length')
echo "Critical vulnerabilities: $CRITICAL"
echo "High vulnerabilities: $HIGH"
if [ "$CRITICAL" -gt "$SECURITY_MAX_CRITICAL" ]; then
echo "::error::$CRITICAL critical vulnerabilities found (max: $SECURITY_MAX_CRITICAL)"
exit 1
fi
if [ "$HIGH" -gt "$SECURITY_MAX_HIGH" ]; then
echo "::error::$HIGH high vulnerabilities found (max: $SECURITY_MAX_HIGH)"
exit 1
fi
# Gate 4: Performance Regression
performance-check:
name: "Gate: Performance Regression < 10%"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Performance Benchmarks
run: npm run test:performance
- name: Compare with Baselines
run: |
python scripts/compare_baselines.py \
--current test-results/performance.json \
--baselines baselines/main.json \
--threshold $PERF_REGRESSION_THRESHOLD \
--output regression-report.json
- name: Check Regression
run: |
REGRESSION=$(cat regression-report.json | jq '.max_regression_percent')
echo "Max regression: $REGRESSION%"
if (( $(echo "$REGRESSION > $PERF_REGRESSION_THRESHOLD" | bc -l) )); then
echo "::warning::Performance regression detected: $REGRESSION% (threshold: $PERF_REGRESSION_THRESHOLD%)"
# Warning only, no exit 1
fi
# Gate 5: E2E Critical Paths
e2e-critical:
name: "Gate: E2E Critical Paths 100% Pass"
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Run iOS E2E Tests (Critical)
run: |
xcodebuild test \
-scheme MedTime-E2E \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:MedTimeE2ETests/CriticalPathTests
- name: Run Android E2E Tests (Critical)
run: ./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.medtime.e2e.CriticalPathTests
- name: Verify Critical Paths
run: |
# Parse test results
FAILED=$(cat test-results/e2e-results.json | jq '[.testcases[] | select(.status == "failed")] | length')
if [ "$FAILED" -gt 0 ]; then
echo "::error::$FAILED critical E2E tests failed"
exit 1
fi
# Summary Gate
all-gates:
name: "All Quality Gates"
needs: [unit-coverage, integration-tests, security-scan, performance-check, e2e-critical]
runs-on: ubuntu-latest
steps:
- name: All Gates Passed
run: |
echo "## Quality Gates Summary" >> $GITHUB_STEP_SUMMARY
echo "| Gate | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Unit Coverage >= 80% | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Integration Tests 100% | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Security 0 High/Critical | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Performance < 10% Regression | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| E2E Critical Paths 100% | ✅ |" >> $GITHUB_STEP_SUMMARY
echo ""
echo "All quality gates passed! ✅"
11.2.2. Branch Protection Rules¶
# Repository Settings > Branches > main
protection_rules:
main:
required_status_checks:
strict: true
contexts:
- "Gate: Unit Test Coverage >= 80%"
- "Gate: Integration Tests 100% Pass"
- "Gate: Security 0 High/Critical"
- "Gate: E2E Critical Paths 100% Pass"
- "All Quality Gates"
required_pull_request_reviews:
required_approving_review_count: 1
dismiss_stale_reviews: true
enforce_admins: true
restrictions: null
develop:
required_status_checks:
strict: true
contexts:
- "Gate: Unit Test Coverage >= 80%"
- "Gate: Integration Tests 100% Pass"
- "Gate: Security 0 High/Critical"
required_pull_request_reviews:
required_approving_review_count: 1
11.2.3. Gate Escalation Matrix¶
| Gate Failure | Severity | Action | Escalation |
|---|---|---|---|
| Unit Coverage < 80% | High | PR Blocked | Dev Lead |
| Unit Coverage < 70% | Critical | PR Blocked + Alert | Tech Lead |
| Integration Test Fail | High | PR Blocked | Dev Lead |
| Security Critical | Critical | PR Blocked + Alert | Security Team |
| Security High | High | PR Blocked | Dev Lead |
| Performance > 10% | Medium | Warning | Dev Team |
| Performance > 25% | High | PR Blocked | Tech Lead |
| E2E Critical Fail | Critical | PR Blocked + Alert | QA Lead |
12. Matriz de Trazabilidad¶
Actualizado: DV2 - Se agregaron 14 modulos faltantes para cobertura completa.
12.1. Modulos Core (P0 - Bloqueantes)¶
| Modulo Funcional | Descripcion | Test Suite | Cobertura Target |
|---|---|---|---|
| MTS-AUTH-001 | Autenticacion | tests/auth/* |
95% |
| MTS-MED-001 | Medicamentos | tests/medications/* |
95% |
| MTS-USR-001 | Usuarios/Perfiles | tests/users/* |
90% |
| MTS-ALT-001 | Alertas/Recordatorios | tests/alerts/* |
95% |
| MTS-NTF-001 | Notificaciones | tests/notifications/* |
90% |
| MTS-SEC-001 | Seguridad/Crypto | tests/security/* |
98% |
| MTS-OFF-001 | Modo Offline | tests/offline/* |
95% |
12.2. Modulos Sync y Datos (P0-P1)¶
| Modulo Funcional | Descripcion | Test Suite | Cobertura Target |
|---|---|---|---|
| MTS-BCK-001 | Backup/Restore | tests/backup/* |
95% |
| MTS-CAT-001 | Catalogo Medicamentos | tests/catalog/* |
85% |
| MTS-RX-001 | Recetas/Prescripciones | tests/prescriptions/* |
90% |
| MTS-ADH-001 | Adherencia | tests/adherence/* |
90% |
12.3. Modulos Secundarios (P1)¶
| Modulo Funcional | Descripcion | Test Suite | Cobertura Target |
|---|---|---|---|
| MTS-CAL-001 | Calendario | tests/calendar/* |
85% |
| MTS-EVT-001 | Eventos de Salud | tests/events/* |
85% |
| MTS-GAM-001 | Gamificacion | tests/gamification/* |
80% |
| MTS-FAM-001 | Familia/Cuidadores | tests/family/* |
90% |
| MTS-CIT-001 | Citas Medicas | tests/appointments/* |
85% |
| MTS-RPT-001 | Reportes | tests/reports/* |
80% |
| MTS-ANA-001 | Analytics | tests/analytics/* |
75% |
12.4. Modulos UI/UX (P2)¶
| Modulo Funcional | Descripcion | Test Suite | Cobertura Target |
|---|---|---|---|
| MTS-ONB-001 | Onboarding | tests/onboarding/* |
85% |
| MTS-WDG-001 | Widgets | tests/widgets/* |
80% |
| MTS-WCH-001 | Watch/Wearables | tests/wearables/* |
75% |
| MTS-INT-001 | Integraciones Salud | tests/integrations/* |
80% |
12.5. Modulos Regulatorios (P1)¶
| Modulo Funcional | Descripcion | Test Suite | Cobertura Target |
|---|---|---|---|
| MTS-PRI-001 | Privacidad/GDPR | tests/privacy/* |
98% |
| MTS-FHIR-001 | Interoperabilidad FHIR | tests/fhir/* |
90% |
| MTS-REG-001 | Cumplimiento Regulatorio | tests/compliance/* |
95% |
12.6. Mapeo Detallado por Funcionalidad¶
| Functional Spec | Test Case | Tipo | Prioridad |
|---|---|---|---|
| MTS-AUTH-001-F01 (Registro) | test_registration_* |
E2E | P0 |
| MTS-AUTH-001-F02 (Login) | test_login_* |
Unit + E2E | P0 |
| MTS-AUTH-001-F03 (Biometrics) | test_biometric_* |
Integration | P0 |
| MTS-MED-001-F01 (Agregar med) | test_addMedication_* |
Unit + E2E | P0 |
| MTS-MED-001-F02 (Editar med) | test_editMedication_* |
Unit + E2E | P0 |
| MTS-MED-001-F03 (Eliminar med) | test_deleteMedication_* |
Unit + E2E | P1 |
| MTS-ALT-001-F01 (Recordatorios) | test_reminder_* |
Unit + E2E | P0 |
| MTS-ALT-001-F02 (Snooze) | test_snooze_* |
Unit | P1 |
| MTS-ALT-001-F03 (Emergencia) | test_emergency_* |
E2E | P0 |
| MTS-SEC-001-F01 (Encryption) | test_encrypt_* |
Unit | P0 |
| MTS-SEC-001-F02 (Key Derivation) | test_argon2_* |
Unit | P0 |
| MTS-SEC-001-F03 (Zero-Knowledge) | test_zeroknowledge_* |
Integration | P0 |
| MTS-OFF-001-F01 (Sync Queue) | test_syncQueue_* |
Unit + Integration | P0 |
| MTS-OFF-001-F02 (Conflict Res) | test_conflict_* |
Integration | P0 |
| MTS-BCK-001-F01 (Auto Backup) | test_autoBackup_* |
Integration | P0 |
| MTS-BCK-001-F02 (Restore) | test_restore_* |
E2E | P0 |
| MTS-ADH-001-F01 (Tracking) | test_adherence_* |
Unit | P1 |
| MTS-ADH-001-F02 (Streaks) | test_streak_* |
Unit | P1 |
| MTS-FAM-001-F01 (Invite) | test_familyInvite_* |
E2E | P1 |
| MTS-FAM-001-F02 (Supervision) | test_supervision_* |
Integration | P1 |
| MTS-PRI-001-F01 (Data Export) | test_gdprExport_* |
E2E | P0 |
| MTS-PRI-001-F02 (Data Delete) | test_gdprDelete_* |
E2E | P0 |
12.7. Resumen de Cobertura¶
| Categoria | Modulos | Cobertura Promedio Target |
|---|---|---|
| Core (P0) | 7 | 94% |
| Sync/Datos (P0-P1) | 4 | 90% |
| Secundarios (P1) | 7 | 84% |
| UI/UX (P2) | 4 | 80% |
| Regulatorios (P1) | 3 | 94% |
| TOTAL | 25 | 88% |
13. Metricas y Reporting¶
13.1. Dashboard de Metricas¶
| Metrica | Target | Actual | Trend |
|---|---|---|---|
| Unit Test Coverage | 80% | - | - |
| Integration Coverage | 90% | - | - |
| E2E Pass Rate | 95% | - | - |
| Security Issues | 0 Critical | - | - |
| P99 Latency | < 2s | - | - |
| Test Execution Time | < 15min | - | - |
13.2. Reporting¶
- Codecov: Cobertura de codigo
- SonarQube: Quality metrics
- Grafana: Performance dashboards
- Slack: Alertas de fallas
14. Referencias¶
| Documento | Proposito |
|---|---|
| 01-arquitectura-tecnica.md | Arquitectura a testear |
| 02-arquitectura-cliente-servidor.md | Arquitectura dual |
| 04-seguridad-cliente.md | Security tests E2E |
| 05-seguridad-servidor.md | Security tests RLS |
| APIs | Contract testing |
| Database Models | DB testing |
| OWASP Testing Guide | Security testing |
Documento generado por TestingDrone / SpecQueen Technical Division - IT-06