MiniSpec - Guía de Usuario
🌍 Idioma: English | Español
Bienvenido a la guía de MiniSpec, el framework BDD para Delphi.
📖 Ver también: Patrones de Testing — Cómo usar BDD para tests unitarios, de integración y E2E.
Tabla de Contenidos
Primeros Pasos
- Primeros Pasos — ¿Por qué MiniSpec? Tu primera especificación. Ejecutando specs.
Conceptos Fundamentales
- El World — Compartiendo estado entre pasos de un escenario
- Scenario Outline — Múltiples ejemplos con tablas de datos
- DataTables — Datos estructurados para pasos complejos
Organización y Reutilización
- Step Bindings — Pasos reutilizables con patrones regex
- Tags y Filtrado — Categorizar y filtrar escenarios
- Rules — Agrupar escenarios por regla de negocio
Verificaciones
- Assertions (Expect) — API fluida para verificaciones y excepciones
Vocabulario y Marcadores
- Vocabulario Gherkin — Referencia completa de keywords
- Pending y NoAction — Steps sin implementar o descriptivos
Configuración Avanzada
- Hooks (Before/After) — Código de setup/teardown a nivel de Feature
- Configuración Global — MiniSpec, SpecContext, FeatureContext
- Inyección de Dependencias — Sistema [Inject] integrado
Ejecución y Salida
- Reporters — Console, JSON, JUnit, Gherkin, Live Dashboard
- Línea de Comandos — Opciones de ejecución
Quick Reference
| Concepto | Uso |
|---|---|
| Feature | Feature('...') — Funcionalidad a especificar |
| Scenario | .Scenario('...') — Ejemplo concreto |
| Given/When/Then | .Given(), .When(), .&Then() — Pasos |
| ScenarioOutline | .ScenarioOutline('...').Examples([...]) — Data-driven |
| Background | .Background — Setup común |
| Rule | .Rule('...') — Agrupar por regla |
| Tags | @tag en descripción — Filtrado |
| World | .UseWorld<T> — Estado del escenario |
Patrones de Testing
Patrones de Testing con MiniSpec
🌍 Idioma: English | Español
Esta guía complementa la Guía de Usuario mostrando cómo adaptar BDD a diferentes niveles de testing. Aunque BDD nació para capturar requisitos de negocio, su vocabulario expresivo es útil en cualquier nivel de la pirámide de tests.
Tabla de Contenidos
- La Pirámide de Tests
- Tests E2E: Especificando el Sistema
- Tests Unitarios: Especificando Clases
- Tests de Integración: Especificando APIs
- Organizando por Tipo de Test
La Pirámide de Tests
/\
/ \ E2E Tests
/ \ (Sistema completo, requisitos de negocio)
/------\
/ \ Integration Tests
/ \ (APIs, servicios, componentes conectados)
/------------\
/ \ Unit Tests
/________________\(Clases individuales, funciones puras)
| Nivel | SUT | Velocidad | Fragilidad | Enfoque |
|---|---|---|---|---|
| E2E | Sistema completo | Lento | Alta | Requisitos de negocio |
| Integración | API / Servicio | Medio | Media | Contratos entre componentes |
| Unitario | Clase / Función | Rápido | Baja | Comportamiento aislado |
MiniSpec puede usarse en los tres niveles. La clave está en qué describes y cómo estructuras tus especificaciones.
Tests E2E: Especificando el Sistema
Este es el uso “clásico” de BDD, cubierto en detalle en la Guía de Usuario. El SUT es el sistema completo y describes requisitos de negocio:
Feature('''
Proceso de Checkout
Como cliente
Necesito completar mi compra
Para recibir los productos en mi domicilio
@e2e @checkout
''')
.UseWorld<TCheckoutWorld>
.Scenario('Compra exitosa con tarjeta de crédito')
.Given('tengo productos en el carrito', procedure(W: TCheckoutWorld)
begin
W.Cart.Add(TProduct.Create('Laptop', 999.99));
end)
.When('completo el pago con tarjeta válida', procedure(W: TCheckoutWorld)
begin
W.Checkout.Pay(TCreditCard.Create('4111111111111111'));
end)
.&Then('recibo confirmación de pedido', procedure(W: TCheckoutWorld)
begin
Expect(W.Checkout.OrderConfirmed).ToBeTrue;
Expect(W.Checkout.OrderNumber).ToMatch('^\d{8}$');
end)Características de tests E2E:
- Vocabulario de negocio, no técnico
- El World orquesta múltiples componentes
- Pueden ser lentos (base de datos real, servicios externos)
- Ideales para criterios de aceptación
Tests Unitarios: Especificando Clases
Cuando el SUT es una clase individual, BDD sigue siendo útil. Piensa en frameworks como RSpec (Ruby) o Jest (JavaScript) que usan describe() para agrupar comportamientos.
El Patrón: Feature = Clase, Scenario = Método/Comportamiento
unit TStringBuilder.Spec.pas;
interface
implementation
uses
System.SysUtils,
Daf.MiniSpec;
type
TStringBuilderWorld = class
public
SUT: TStringBuilder; // System Under Test
Result: string;
destructor Destroy; override;
end;
destructor TStringBuilderWorld.Destroy;
begin
SUT.Free;
inherited;
end;
initialization
Feature('''
TStringBuilder @unit
Clase para construir strings eficientemente
mediante concatenación incremental.
''')
.UseWorld<TStringBuilderWorld>
.Background
.Given('un StringBuilder vacío', procedure(W: TStringBuilderWorld)
begin
W.SUT := TStringBuilder.Create;
end)
.Rule('Append: añade texto al final')
.Scenario('Append de un string')
.When('añado "Hello"', procedure(W: TStringBuilderWorld)
begin
W.SUT.Append('Hello');
end)
.&Then('el contenido es "Hello"', procedure(W: TStringBuilderWorld)
begin
Expect(W.SUT.ToString).ToEqual('Hello');
end)
.Scenario('Append encadenado')
.When('añado "Hello" y luego " World"', procedure(W: TStringBuilderWorld)
begin
W.SUT.Append('Hello').Append(' World');
end)
.&Then('el contenido es "Hello World"', procedure(W: TStringBuilderWorld)
begin
Expect(W.SUT.ToString).ToEqual('Hello World');
end)
.Rule('Clear: vacía el contenido')
.Scenario('Clear después de añadir')
.Given('contenido existente', procedure(W: TStringBuilderWorld)
begin
W.SUT.Append('Existing content');
end)
.When('llamo Clear', procedure(W: TStringBuilderWorld)
begin
W.SUT.Clear;
end)
.&Then('el contenido está vacío', procedure(W: TStringBuilderWorld)
begin
Expect(W.SUT.ToString).ToEqual('');
Expect(W.SUT.Length).ToEqual(0);
end)
.Rule('Length: devuelve la longitud actual')
.Scenario('Length inicial es cero')
.&Then('Length es 0', procedure(W: TStringBuilderWorld)
begin
Expect(W.SUT.Length).ToEqual(0);
end)
.ScenarioOutline('Length después de Append')
.When('añado <texto>', procedure(W: TStringBuilderWorld)
begin
W.SUT.Append(W.Texto);
end)
.&Then('Length es <longitud>', procedure(W: TStringBuilderWorld)
begin
Expect(W.SUT.Length).ToEqual(W.Longitud);
end)
.Examples([
['texto', 'longitud'],
['', 0],
['a', 1],
['Hello', 5],
['こんにちは', 5] // Unicode
])
end.Convenciones para Tests Unitarios
| Elemento | Convención | Ejemplo |
|---|---|---|
| Feature | Nombre de la clase | TStringBuilder, TCalculator |
| Rule | Método o grupo de comportamiento | Append: añade texto, Validación de entrada |
| Scenario | Caso específico del comportamiento | Append de string vacío |
| Tag | @unit para filtrado |
TStringBuilder @unit |
| World | Contiene solo el SUT y datos del test | TStringBuilderWorld |
Comparación con RSpec
Si vienes de RSpec, esta es la equivalencia:
# RSpec (Ruby)
describe TStringBuilder do
describe '#append' do
it 'adds text to the end' do
builder = TStringBuilder.new
builder.append('Hello')
expect(builder.to_s).to eq('Hello')
end
end
end// MiniSpec (Delphi)
Feature('TStringBuilder @unit')
.UseWorld<TWorld>
.Rule('Append: añade texto al final')
.Scenario('Append de un string')
.Given('un StringBuilder vacío', ...)
.When('añado "Hello"', ...)
.&Then('el contenido es "Hello"', ...)La estructura es similar: - describe Class → Feature('Class') - describe '#method' → .Rule('Method: descripción') - it 'behavior' → .Scenario('behavior')
Tests de Integración: Especificando APIs
Cuando el SUT es un API (REST, GraphQL, gRPC…), cada endpoint o operación puede ser una Feature o Rule.
El Patrón: Feature = Recurso/Endpoint, Scenario = Operación
unit API.Users.Spec.pas;
interface
implementation
uses
System.SysUtils,
System.JSON,
System.Net.HttpClient,
Daf.MiniSpec;
type
TApiWorld = class
public
Client: THTTPClient;
Response: IHTTPResponse;
ResponseJson: TJSONObject;
UserId: string;
destructor Destroy; override;
end;
destructor TApiWorld.Destroy;
begin
ResponseJson.Free;
Client.Free;
inherited;
end;
initialization
Feature('''
API: /users @integration @api
Gestión de usuarios via REST API.
Base URL: http://localhost:3000/api/v1
''')
.UseWorld<TApiWorld>
.Background
.Given('un cliente HTTP configurado', procedure(W: TApiWorld)
begin
W.Client := THTTPClient.Create;
W.Client.ContentType := 'application/json';
end)
.Rule('GET /users - Listar usuarios')
.Scenario('Lista vacía cuando no hay usuarios')
.When('GET /users', procedure(W: TApiWorld)
begin
W.Response := W.Client.Get('http://localhost:3000/api/v1/users');
end)
.&Then('responde 200 OK', procedure(W: TApiWorld)
begin
Expect(W.Response.StatusCode).ToEqual(200);
end)
.&And('devuelve array vacío', procedure(W: TApiWorld)
begin
var Json := TJSONObject.ParseJSONValue(W.Response.ContentAsString);
Expect(Json is TJSONArray).ToBeTrue;
Expect((Json as TJSONArray).Count).ToEqual(0);
Json.Free;
end)
.Rule('POST /users - Crear usuario')
.Scenario('Crear usuario con datos válidos')
.When('POST /users con nombre y email', procedure(W: TApiWorld)
begin
var Body := TJSONObject.Create;
Body.AddPair('name', 'John Doe');
Body.AddPair('email', 'john@example.com');
W.Response := W.Client.Post(
'http://localhost:3000/api/v1/users',
TStringStream.Create(Body.ToString)
);
Body.Free;
end)
.&Then('responde 201 Created', procedure(W: TApiWorld)
begin
Expect(W.Response.StatusCode).ToEqual(201);
end)
.&And('devuelve el usuario con ID', procedure(W: TApiWorld)
begin
W.ResponseJson := TJSONObject.ParseJSONValue(
W.Response.ContentAsString) as TJSONObject;
Expect(W.ResponseJson.GetValue('id')).ToNotBeNull;
Expect(W.ResponseJson.GetValue<string>('name')).ToEqual('John Doe');
W.UserId := W.ResponseJson.GetValue<string>('id');
end)
.Scenario('Error con email inválido')
.When('POST /users con email malformado', procedure(W: TApiWorld)
begin
var Body := TJSONObject.Create;
Body.AddPair('name', 'John');
Body.AddPair('email', 'not-an-email');
W.Response := W.Client.Post(
'http://localhost:3000/api/v1/users',
TStringStream.Create(Body.ToString)
);
Body.Free;
end)
.&Then('responde 400 Bad Request', procedure(W: TApiWorld)
begin
Expect(W.Response.StatusCode).ToEqual(400);
end)
.&And('incluye mensaje de error', procedure(W: TApiWorld)
begin
W.ResponseJson := TJSONObject.ParseJSONValue(
W.Response.ContentAsString) as TJSONObject;
Expect(W.ResponseJson.GetValue<string>('error')).ToContain('email');
end)
.Rule('GET /users/{id} - Obtener usuario')
.Scenario('Usuario existente')
.Given('un usuario existente', procedure(W: TApiWorld)
begin
// Crear usuario primero
var Body := TJSONObject.Create;
Body.AddPair('name', 'Jane');
Body.AddPair('email', 'jane@example.com');
var Resp := W.Client.Post(
'http://localhost:3000/api/v1/users',
TStringStream.Create(Body.ToString)
);
var Json := TJSONObject.ParseJSONValue(Resp.ContentAsString) as TJSONObject;
W.UserId := Json.GetValue<string>('id');
Json.Free;
Body.Free;
end)
.When('GET /users/{id}', procedure(W: TApiWorld)
begin
W.Response := W.Client.Get(
'http://localhost:3000/api/v1/users/' + W.UserId);
end)
.&Then('responde 200 OK', procedure(W: TApiWorld)
begin
Expect(W.Response.StatusCode).ToEqual(200);
end)
.&And('devuelve los datos del usuario', procedure(W: TApiWorld)
begin
W.ResponseJson := TJSONObject.ParseJSONValue(
W.Response.ContentAsString) as TJSONObject;
Expect(W.ResponseJson.GetValue<string>('name')).ToEqual('Jane');
end)
.Scenario('Usuario no existente')
.When('GET /users/999999', procedure(W: TApiWorld)
begin
W.Response := W.Client.Get(
'http://localhost:3000/api/v1/users/999999');
end)
.&Then('responde 404 Not Found', procedure(W: TApiWorld)
begin
Expect(W.Response.StatusCode).ToEqual(404);
end)
end.Usando FeatureContext para Estado Compartido
En tests de API, a menudo necesitas datos creados en un escenario para usarlos en otro:
type
TApiContext = class
public
BaseUrl: string;
AuthToken: string;
CreatedIds: TList<string>;
constructor Create;
destructor Destroy; override;
end;
TApiWorld = class
private
[Inject] FCtx: TApiContext;
public
Client: THTTPClient;
Response: IHTTPResponse;
property Ctx: TApiContext read FCtx;
end;
Feature('API: /orders @integration')
.UseFeatureContext<TApiContext>
.UseWorld<TApiWorld>
.Before('Iniciar servidor de test', procedure
begin
TestServer.Start;
end)
.After('Limpiar datos de test', procedure
begin
TestServer.CleanupTestData;
TestServer.Stop;
end)Tabla de Códigos HTTP Comunes
Para verificar respuestas HTTP:
// Helpers reutilizables
procedure ExpectSuccess(Response: IHTTPResponse);
begin
Expect(Response.StatusCode).ToBeBetween(200, 299);
end;
procedure ExpectClientError(Response: IHTTPResponse);
begin
Expect(Response.StatusCode).ToBeBetween(400, 499);
end;
procedure ExpectServerError(Response: IHTTPResponse);
begin
Expect(Response.StatusCode).ToBeBetween(500, 599);
end;Organizando por Tipo de Test
Hay dos estrategias principales: un solo ejecutable con tags o ejecutables separados por nivel.
Opción B: Ejecutables Separados por Nivel
Cada nivel de testing tiene su propio proyecto .dpr. Ideal cuando los niveles tienen dependencias muy distintas o para pipelines CI/CD que ejecutan en paralelo.
MyProject/
├── src/ # Código de producción
├── specs/
│ ├── Specs.groupproj # Agrupa todos los proyectos de specs
│ │
│ ├── unit/
│ │ ├── UnitSpecs.dpr # Ejecutable: solo tests unitarios
│ │ ├── UnitSpecs.dproj
│ │ ├── TCalculator.Spec.pas
│ │ ├── TStringBuilder.Spec.pas
│ │ └── TValidator.Spec.pas
│ │
│ ├── integration/
│ │ ├── IntegrationSpecs.dpr # Ejecutable: solo integración
│ │ ├── IntegrationSpecs.dproj
│ │ ├── API.Users.Spec.pas
│ │ ├── API.Orders.Spec.pas
│ │ └── Database.Spec.pas
│ │
│ └── e2e/
│ ├── E2ESpecs.dpr # Ejecutable: solo E2E
│ ├── E2ESpecs.dproj
│ ├── Checkout.Feat.pas
│ ├── Registration.Feat.pas
│ └── Search.Feat.pas
Cada ejecutable configura su nivel:
// UnitSpecs.dpr - Rápido, sin dependencias externas
program UnitSpecs;
{$APPTYPE CONSOLE}
uses
Daf.MiniSpec,
TCalculator.Spec in 'TCalculator.Spec.pas',
TStringBuilder.Spec in 'TStringBuilder.Spec.pas',
TValidator.Spec in 'TValidator.Spec.pas';
begin
MiniSpec
.Category('Unit Tests')
.Run;
end.
// IntegrationSpecs.dpr - Requiere servicios externos
program IntegrationSpecs;
{$APPTYPE CONSOLE}
uses
Daf.MiniSpec,
TestServer in '..\common\TestServer.pas',
API.Users.Spec in 'API.Users.Spec.pas',
API.Orders.Spec in 'API.Orders.Spec.pas';
begin
MiniSpec
.Category('Integration Tests')
.Before('Start test server', procedure
begin
TestServer.Start;
end)
.After('Stop test server', procedure
begin
TestServer.Stop;
end)
.Run;
end.
// E2ESpecs.dpr - Sistema completo
program E2ESpecs;
{$APPTYPE CONSOLE}
uses
Daf.MiniSpec,
Checkout.Feat in 'Checkout.Feat.pas',
Registration.Feat in 'Registration.Feat.pas';
begin
MiniSpec
.Category('E2E Tests')
.Before('Initialize full system', procedure
begin
TestEnvironment.Setup;
end)
.After('Cleanup', procedure
begin
TestEnvironment.Teardown;
end)
.Run;
end.Ventajas: Ejecutables pequeños, dependencias separadas, fácil paralelizar en CI.
Desventajas: Más proyectos que mantener.
Resumen: Cuándo Usar Cada Nivel
| Pregunta | Unit | Integration | E2E |
|---|---|---|---|
| ¿El SUT es una clase aislada? | ✓ | ||
| ¿Pruebas un API o servicio? | ✓ | ||
| ¿Describes requisitos de negocio? | ✓ | ||
| ¿Necesitas base de datos real? | A veces | ✓ | |
| ¿El test debe ser muy rápido? | ✓ | ||
| ¿Stakeholders no técnicos lo leerán? | ✓ |
Conclusión
BDD no es exclusivo de tests E2E. El vocabulario de MiniSpec — Feature, Rule, Scenario, Given/When/Then — proporciona una estructura clara para cualquier nivel de testing:
- Unit tests: Feature = Clase, Rule = Método
- Integration tests: Feature = API/Recurso, Rule = Endpoint
- E2E tests: Feature = Funcionalidad de negocio, Scenario = Criterio de aceptación
La clave está en adaptar el vocabulario y la granularidad al nivel que estás probando, manteniendo siempre el principio fundamental de BDD: especificar comportamiento con ejemplos concretos.