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 [MiniInject] integrado
Ejecución y Salida
- Reporters — Console, JSON, JUnit, Gherkin, Live Dashboard
- Línea de Comandos — Opciones de ejecución
- Ejecuciones anidadas —
TSpecRun: una suite ejecutada dentro de otra
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 |
Primeros Pasos
Primeros Pasos con MiniSpec
🌍 Idioma: English | Español
¿Por qué MiniSpec?
Imagina que pudieras escribir los requisitos de tu aplicación de forma que:
- Cualquiera pueda leerlos — desarrolladores, testers, analistas, clientes
- Se ejecuten automáticamente — verificando que el código cumple lo especificado
- Nunca queden desactualizados — porque son el propio test
Esta idea tiene un nombre: Especificación por Ejemplos (Specification by Example). En lugar de documentos de requisitos que nadie lee y que se desactualizan al primer cambio, escribes ejemplos concretos del comportamiento esperado. Estos ejemplos se convierten en especificaciones ejecutables: documentación que se valida automáticamente.
Esta práctica es el corazón de BDD (Behavior-Driven Development), una metodología ampliamente adoptada en la industria para capturar requisitos de forma colaborativa. El vocabulario que usa BDD se llama Gherkin y lo reconocerás por sus palabras clave: Feature, Scenario, Given, When, Then. Herramientas como Cucumber, SpecFlow y Behave llevan años demostrando el valor de este enfoque.
MiniSpec trae BDD a Delphi, usando el vocabulario Gherkin de forma nativa:
- Feature: La funcionalidad que estás especificando
- Scenario: Un ejemplo concreto de cómo debe comportarse
- Given-When-Then: El patrón que estructura cada ejemplo
El resultado es código que documenta, especifica y verifica al mismo tiempo.
Tu Primera Especificación
Imagina que estás desarrollando una calculadora. Antes de escribir código, describes cómo debería comportarse:
“Cuando sumo 2 y 3, el resultado debería ser 5”
Esta frase simple es una especificación ejecutable. Con MiniSpec, la escribes casi literalmente (el código del sample está en inglés):
unit Addition.Feat;
interface
implementation
uses
Daf.MiniSpec,
Calculator.Engine; // Here's TCalculator, the system we're specifying
type
TCalculatorWorld = class
public
Calculator: TCalculator; // The SUT (System Under Test)
destructor Destroy; override;
end;
destructor TCalculatorWorld.Destroy;
begin
Calculator.Free;
inherited;
end;
initialization
Feature('Calculator - Addition')
.UseWorld<TCalculatorWorld>
.Background
.Given('I have a calculator', procedure(Ctx: TCalculatorWorld)
begin
Ctx.Calculator := TCalculator.Create;
end)
.Scenario('Add two positive numbers')
.When('I add 2 and 3', procedure(Ctx: TCalculatorWorld)
begin
Ctx.Calculator.Add(2, 3);
end)
.&Then('the result is 5', procedure(Ctx: TCalculatorWorld)
begin
Expect(Ctx.Calculator.Result).ToEqual(5);
end)
end.La especificación define el comportamiento de TCalculator — el SUT (System Under Test). No nos importa cómo está implementado, solo qué debe hacer. La implementación es Calculator.Engine.pas, del sample samples/CalculatorSpecs/:
unit Calculator.Engine;
interface
uses
System.SysUtils;
type
TCalculator = class
strict private
FResult: Integer;
public
procedure Add(A, B: Integer);
procedure Subtract(A, B: Integer);
procedure Mult(A, B: Integer);
procedure Divide(A, B: Integer);
property Result: Integer read FResult;
end;
implementation
procedure TCalculator.Add(A, B: Integer);
begin
FResult := A + B;
end;
procedure TCalculator.Subtract(A, B: Integer);
begin
FResult := A - B;
end;
procedure TCalculator.Mult(A, B: Integer);
begin
FResult := A * B;
end;
procedure TCalculator.Divide(A, B: Integer);
begin
if B = 0 then
raise EDivByZero.Create('Division by zero');
FResult := A div B;
end;
end.¿Qué está pasando aquí?
- Feature declara qué funcionalidad estamos especificando
- UseWorld define una clase que contiene el SUT y los datos del test
- Background prepara el contexto común a todos los escenarios (crea la calculadora)
- Scenario describe un caso concreto de uso
- When ejecuta la acción que queremos probar (usa el SUT)
- Then verifica que el resultado sea el esperado
💡 Los pasos siguen el patrón Given-When-Then: “Dado que… Cuando… Entonces…”. Este patrón simple pero poderoso te obliga a pensar en precondiciones, acciones y resultados.
Ejecutando la Especificación
Para que la especificación sea ejecutable, necesitas un programa mínimo. Junto con la feature de arriba, es el sample samples/FirstSpec/:
program FirstSpec;
{$APPTYPE CONSOLE}
uses
Daf.MiniSpec,
Calculator.Engine in '..\CalculatorSpecs\Calculator.Engine.pas',
Addition.Feat in 'Addition.Feat.pas';
begin
MiniSpec.Run;
end.Al ejecutar, verás en consola:
Feature: Calculator - Addition
Background:
✓ Given I have a calculator (0 ms)
Scenario: Add two positive numbers
✓ When I add 2 and 3 (0 ms)
✓ Then the result is 5 (2 ms)
Passed: 1 | Failed: 0 | Pending: 0 | Undefined: 0 | Skipped: 0 | Total: 1 Specs in 1 Features | 0 ms | at 2026-09-30T11:15:11
El ciclo es simple: describes el comportamiento esperado → ejecutas → MiniSpec te dice si el código cumple la especificación.
← Volver a la Guía | Siguiente: El World →
El World
El World: Compartiendo Estado
🌍 Idioma: English | Español
El World es el pegamento entre los pasos de un escenario. Cada paso recibe la misma instancia, permitiendo:
- Given prepara el estado inicial
- When es el evento, uno y nombrado, que ejercita el SUT sobre ese estado
- Then verifica el resultado observable
MiniSpec crea y destruye el World automáticamente para cada escenario. Por eso tu clase World debe tener un constructor sin parámetros (el constructor por defecto de TObject es suficiente). Si necesitas liberar recursos, sobrescribe Destroy:
type
TCalculatorWorld = class
public
Calculator: TCalculator; // El objeto bajo prueba
destructor Destroy; override;
end;
destructor TCalculatorWorld.Destroy;
begin
Calculator.Free;
inherited;
end;Cada escenario recibe un World nuevo. Esto garantiza que los escenarios estén aislados entre sí: lo que hace un escenario no afecta a otros.
Feature('...')
.UseWorld<TCalculatorWorld> // MiniSpec crea una instancia por escenario
.Scenario('Primer test') // World #1
// ...
.Scenario('Segundo test') // World #2 (independiente del anterior)
// ...← Primeros Pasos | Siguiente: Scenario Outline →
Scenario Outline
Múltiples Ejemplos con Scenario Outline
🌍 Idioma: English | Español
Un solo ejemplo rara vez es suficiente. ¿Qué pasa con números negativos? ¿Con cero? Podrías escribir varios escenarios casi idénticos… o usar Scenario Outline:
.ScenarioOutline('Sumar <A> y <B> debería dar <Resultado>')
.When('sumo <A> y <B>', procedure(Ctx: TCalculatorWorld)
begin
Ctx.Calculator.Add(Ctx.A, Ctx.B);
end)
.&Then('el resultado es <Resultado>', procedure(Ctx: TCalculatorWorld)
begin
Expect(Ctx.Calculator.Result).ToEqual(Ctx.Resultado);
end)
.Examples([
['A', 'B', 'Resultado'], // Headers = nombres de campos del World
[ 2, 3, 5], // Ejemplo 1
[ 0, 0, 0], // Ejemplo 2
[-1, 1, 0], // Ejemplo 3
[100, -50, 50] // Ejemplo 4
])¿Qué ocurre aquí?
- Los placeholders
<A>,<B>,<Resultado>en las descripciones se reemplazan por los valores concretos - MiniSpec lee los valores de la tabla Examples y los inyecta en las propiedades correspondientes del World (por eso el World debe tener propiedades
A,ByResultado) - El escenario se ejecuta una vez por cada fila de la tabla Examples, cada uno con un World nuevo (como cualquier escenario normal)
El resultado muestra el outline con su tabla de ejemplos:
Feature: Calculadora - Suma
Background:
✓ Given tengo una calculadora (0 ms)
Scenario Outline: Sumar <A> y <B> debería dar <Resultado>
When sumo <A> y <B>
Then el resultado es <Resultado>
Examples:
| A | B | Resultado |
✓ | 2 | 3 | 5 | (0 ms)
✓ | 0 | 0 | 0 | (0 ms)
✓ | -1 | 1 | 0 | (0 ms)
✓ | 100 | -50 | 50 | (0 ms)
Passed: 4 | Failed: 0 | Pending: 0 | Undefined: 0 | Skipped: 0 | Total: 4 Specs in 1 Features | 0 ms | at 2026-01-30T14:57:07
💡 Un Scenario normal es simplemente un Outline con un solo ejemplo implícito. No hay diferencia conceptual: ambos especifican comportamiento con ejemplos concretos. El Outline solo hace explícita la tabla de datos.
Steps display-only
Un step definido sin lambda es display-only: aparece en el reporte pero se ejecuta como no-op. Es útil para el Given de outlines donde los datos ya vienen de la tabla Examples vía inyección RTTI — no hace falta código:
.ScenarioOutline('Sumar <A> y <B> debería dar <Resultado>')
.Given('los números <A> y <B>') // display-only — valores inyectados desde Examples
.When('se suman', procedure(Ctx: TCalculatorWorld)
begin
Ctx.Resultado := Ctx.A + Ctx.B;
end)
...La línea Given aparece en la salida como contexto pero no ejecuta código. Si existe un step binding que coincide con la descripción sustituida, se ejecuta en su lugar.
Steps pendientes, y un outline sin Examples
Un step declarado .Pending en el outline queda pendiente en cada fila: ninguna lo ejecuta, ni a los que le siguen, y cada fila cuenta como Pending.
Un outline sin .Examples se ejecuta una vez, como un escenario: con su World y su Background, contado como un escenario e impreso con sus steps. Ahí un step sin lambda no es display-only: si no hay binding que case con él, es Undefined, como en un Scenario.
← El World | Siguiente: DataTables →
DataTables
DataTables: Datos Estructurados
🌍 Idioma: English | Español
A veces un paso necesita datos más complejos que simples valores. Las DataTables permiten pasar estructuras tabulares:
.Scenario('Crear múltiples usuarios')
.Given('los siguientes usuarios:', [
['nombre', 'email', 'rol'],
['Alice', 'alice@test.com', 'admin'],
['Bob', 'bob@test.com', 'user'],
['Carol', 'carol@test.com', 'user']
], procedure(World: TMyWorld)
begin
var Table := SpecContext.DataTable;
for var I := 0 to Table.RowCount - 1 do
begin
var Row := Table.AsMap(I); // Fila como diccionario
World.Input.Add( // datos de entrada, aún sin procesar
Row['nombre'].AsString,
Row['email'].AsString,
Row['rol'].AsString
);
end;
end)
.When('los importo en el directorio', procedure(World: TMyWorld)
begin
World.Directory.Import(World.Input); // el SUT: aquí ocurre el evento
end)
.&Then('el directorio tiene 3 usuarios', procedure(World: TMyWorld)
begin
Expect(World.Directory.Count).ToEqual(3);
end)API de DataTable
| Propiedad/Método | Descripción |
|---|---|
RowCount |
Número de filas (sin contar headers) |
ColCount |
Número de columnas |
Headers |
Array con nombres de columnas |
Cell[row, col] |
Celda por índices |
Cell[row, 'name'] |
Celda por nombre de columna |
AsMap(row) |
Fila como TDictionary<string, TValue> |
AsList |
Todas las filas como array de diccionarios |
Transpose |
Nueva tabla con filas/columnas intercambiadas |
← Scenario Outline | Siguiente: Step Bindings →
Step Bindings
Step Bindings: Organizando Pasos Complejos
🌍 Idioma: English | Español
Cuando tus especificaciones crecen, notarás que muchos pasos se repiten:
// En Feature A:
.Given('un usuario autenticado', procedure(W: TWorldA) begin ... end)
// En Feature B:
.Given('un usuario autenticado', procedure(W: TWorldB) begin ... end) // ¡Duplicado!Los Step Bindings resuelven esto: defines pasos como métodos de una clase, usando patrones regex:
unit Auth.Steps;
interface
uses
Daf.MiniSpec,
Daf.MiniSpec.Binding;
type
TAuthBindings = class
public
[Given('un usuario "([^"]+)" con password "([^"]+)"')]
procedure SetupUser(World: TObject; Username, Password: string);
[When('hace login')]
procedure DoLogin(World: TObject);
[ThenAttribute('el login es exitoso')]
procedure VerifyLoginSuccess(World: TObject);
[ThenAttribute('el login falla con "([^"]+)"')]
procedure VerifyLoginError(World: TObject; ExpectedError: string);
end;
implementation
procedure TAuthBindings.SetupUser(World: TObject; Username, Password: string);
begin
var W := World as TAuthWorld;
W.Username := Username;
W.Password := Password;
end;
// ... resto de implementacionesRegistrar y Usar
initialization
Bindings.RegisterSteps<TAuthBindings>;
Feature('Autenticación')
.UseWorld<TAuthWorld>
.Scenario('Login válido')
.Given('un usuario "admin" con password "secret123"') // Usa el binding
.When('hace login')
.&Then('el login es exitoso')
.Scenario('Password incorrecto')
.Given('un usuario "admin" con password "wrong"')
.When('hace login')
.&Then('el login falla con "Credenciales inválidas"')Características de los Bindings
| Aspecto | Descripción |
|---|---|
| Patrones | Regex con grupos de captura para parámetros |
| Tipos | Integer, Int64, Float, string, Boolean |
| Primer parámetro | Siempre el World (usa TObject y haz cast) |
| Prioridad | Lambda inline > Binding registrado |
| Atributos | [GivenAttribute], [WhenAttribute], [ThenAttribute] |
💡 Los bindings son ideales para pasos comunes (autenticación, setup de datos, etc.) que se usan en múltiples features.
← DataTables | Siguiente: Tags y Filtrado →
Rules: Agrupando por Regla de Negocio
🌍 Idioma: English | Español
Cuando una Feature tiene múltiples reglas de negocio, las Rules ayudan a organizar los escenarios que ilustran cada regla:
Feature('Sistema de Descuentos')
.UseWorld<TDiscountWorld>
.Rule('Los clientes VIP tienen 20% de descuento')
.Scenario('Compra normal de cliente VIP')
.Given('un cliente VIP', procedure(W: TDiscountWorld)
begin
W.Customer := TCustomer.Create(TCustomerType.VIP);
end)
.When('compra un producto de $100', procedure(W: TDiscountWorld)
begin
W.Purchase := W.Customer.Buy(100);
end)
.&Then('el total es $80', procedure(W: TDiscountWorld)
begin
Expect(W.Purchase.Total).ToEqual(80);
end)
.Rule('Las compras mayores a $100 tienen envío gratis')
.Scenario('Compra de $150')
// ...
.Scenario('Compra de $50')
// ...Comportamiento de Rules
| Aspecto | Descripción |
|---|---|
| Agrupación | Los escenarios bajo una Rule comparten esa regla de negocio |
| No anidables | Cada .Rule() cierra la anterior automáticamente |
| Background | El Background de la Feature aplica a todos los escenarios, incluidos los de Rules |
| Filtrado | Usa Rule:texto para filtrar por descripción de rule |
EndRule: Escenarios sin Rule
.EndRule solo es necesario cuando quieres añadir escenarios sin Rule después de una Rule:
Feature('...')
.Rule('Una regla específica')
.Scenario('Test dentro de la Rule')
.EndRule // Necesario para salir de la Rule
.Scenario('Escenario general sin Rule')← Assertions | Siguiente: Vocabulario Gherkin →
Assertions (Expect)
Assertions (Expect)
🌍 Idioma: English | Español
MiniSpec proporciona una API fluida para verificaciones:
// Valores
Expect(resultado).ToEqual(5);
Expect(valor).ToNotEqual(0);
Expect(numero).ToBeBetween(1, 10);
// Strings
Expect(mensaje).ToContain('error');
Expect(email).ToMatch('^[\w.-]+@[\w.-]+\.\w+$');
// Booleanos y nulos
Expect(activo).ToBeTrue;
Expect(objeto).ToNotBeNull;Matchers de valor
Todos están en el registro que devuelve Expect. Comparan a través de un Variant, así que la sobrecarga que llames no decide nada sobre la comparación.
| Matcher | Pasa cuando |
|---|---|
ToEqual(V) / ToEqual<T>(V) |
El valor es igual a V |
ToNotEqual(V) / ToNotEqual<T>(V) |
No lo es |
ToBeGreaterThan(V) ToBeGreaterOrEqual(V) |
Comparación de orden |
ToBeLessThan(V) ToBeLessOrEqual(V) |
Lo mismo al revés |
ToBeBetween(Min, Max) |
Min <= valor <= Max, los dos extremos incluidos |
ToBeCloseTo(V, Tolerancia) |
Abs(valor - V) <= Tolerancia. Tolerancia vale 0.0001 si no se pasa |
ToBeWithinPercent(V, Porcentaje) |
Lo mismo, con la tolerancia como porcentaje de V |
ToBeTrue / ToBeFalse |
El valor es ese booleano |
ToBeNull / ToNotBeNull |
El variant es Null, o no lo es |
ToBeEmpty |
El variant es Empty, o su forma de cadena es '' |
ToContain(S) |
La forma de cadena contiene S, ignorando mayúsculas |
ToStartWith(S) / ToEndWith(S) |
Empieza o acaba por S, también ignorando mayúsculas |
ToMatch(Patrón) |
TRegEx.IsMatch sobre la forma de cadena — una expresión regular, no un glob |
ToHaveCount(N) |
El valor es un array variant con N elementos. Cualquier otra cosa falla con Expected array value |
⚠️ ToBeEmpty no imprime el valor que encontró. Su fallo dice Expected value to be empty y nada más, así que no puede nombrar lo que había. Cuando el fallo tiene que nombrar algo, afirma con ToEqual contra un centinela.
Verificando Excepciones
Un caso especial importante: verificar que el código lanza una excepción. MiniSpec captura automáticamente las excepciones en el step When, permitiendo verificarlas en el Then:
.Scenario('División por cero')
.Given('los números 10 y 0', procedure(World: TWorld)
begin
World.A := 10;
World.B := 0;
end)
.When('se divide', procedure(World: TWorld)
begin
World.Calculator.Divide(World.A, World.B); // Excepción capturada
end)
.&Then('lanza EDivByZero', procedure(World: TWorld)
begin
ExpectException(Raised).ToBe(EDivByZero);
end);Métodos para Excepciones
| Método | Descripción |
|---|---|
ExpectException(Raised).ToBeAny |
Se lanzó cualquier excepción |
ExpectException(Raised).ToBe(EMyException) |
Se lanzó tipo específico |
ExpectException(Raised).ToBe<EMyException> |
Lo mismo, escrito como argumento de tipo |
ExpectException(Raised).ToHaveMessage('texto') |
Mensaje contiene substring |
ExpectException(Raised).ToBeNone |
No se lanzó excepción |
ExpectException(Raised).ToMatchMessage('re') |
El mensaje casa con una expresión regular |
Raised es la excepción que escapó del step When. ExpectException recibe también la llamada misma, para una comprobación que no es el evento del escenario:
ExpectException(
procedure
begin
World.Calculator.Divide(10, 0);
end).ToBe<EDivByZero>;⚠️ ToMatchMessage está en TExpectException, no en TExpect: llamarlo sobre un valor da E2003 Undeclared identifier. Y es una regex, así que '*Between*2*1*' no casa con nada donde 'Between.*2.*1' sí.
← Tags y Filtrado | Siguiente: Rules →
Vocabulario Gherkin
Vocabulario Gherkin
🌍 Idioma: English | Español
Referencia completa del vocabulario Gherkin en MiniSpec:
| Palabra clave | MiniSpec | Descripción |
|---|---|---|
| Feature | Feature('...') |
Funcionalidad que se especifica |
| Rule | .Rule('...') |
Agrupa escenarios bajo una regla de negocio |
| Background | .Background |
Pasos comunes a todos los escenarios; si uno falla, el escenario falla sin ejecutar sus propios pasos |
| Scenario | .Scenario('...') |
Un ejemplo concreto de comportamiento |
| Scenario Outline | .ScenarioOutline('...') |
Ejemplo parametrizado con tabla |
| Examples | .Examples([...]) |
Tabla de datos para el Outline |
| DataTable | Tras la descripción del step | Tabla de datos estructurados para un step |
| Given | .Given('...', proc) |
Establece el contexto inicial |
| When | .When('...', proc) |
La acción que se prueba |
| Then | .&Then('...', proc) |
El resultado esperado (& requerido) |
| And | .&And('...', proc) |
Paso adicional (& requerido) |
| But | .But('...', proc) |
Excepción o negación |
| @tag | En descripción | Para filtrar escenarios |
💡 El
&antes deThenyAndes necesario porque son palabras reservadas en Delphi.
Qué afirma cada fase
Un escenario afirma el comportamiento de un evento. Las tres fases son esa afirmación repartida, así que cada una tiene su trabajo y una pieza que no le toca:
| Fase | Afirma | No le toca |
|---|---|---|
Given |
el estado que el evento necesita para tener sentido | provocar el evento |
When |
el evento, uno y nombrado | comprobar nada |
Then |
lo que se puede observar después | provocar nada |
La prueba, escenario a escenario: nombra el evento. ¿Cuál es la acción cuyo comportamiento afirmo aquí? Si se puede nombrar, va al When. Si no se puede, el escenario no tiene sujeto — y eso no se arregla moviendo el código a otra fase.
⚠️ Un escenario con el evento fuera del When sigue pasando y fallando, así que no hay rojo que te lo diga. Lo que se pierde es esto:
- Evento en el
Given— un fallo del evento se informa como fallo de montaje, y manda al lector al sitio equivocado. Y elThensolo puede comparar lo que elGivendejó escrito, así que la aserción mide el montaje en vez del sujeto. - Evento en el
Then— el fallo se informa como aserción fallida. Con variosThen, o cada uno repite el evento —y entonces son eventos distintos, no el mismo observado dos veces— o solo el primero lo ejecuta.
Ver Pending y NoAction para qué significa dejar una fase vacía.
Features con Narrativa
Una Feature puede incluir una narrativa que explica el por qué:
Feature('''
Calculadora - División
Como usuario de la calculadora
Necesito poder dividir números
Para resolver problemas matemáticos
@math @division
''')La primera línea es el título. MiniSpec toma el resto como narrativa. Los tags pueden aparecer en cualquier sitio de la descripción pero se recomienda ponerlos al final.
Category para Filtrado
Asigna categorías para filtrar con Cat:texto. Puedes usar un string o una clase como marcador:
// Con string
Feature('Login')
.Category('auth')
.UseWorld<TLoginWorld>
// Con clase marcadora
type
TIntegrationTest = class end; // Clase vacía como marcador
Feature('Database operations')
.Category(TIntegrationTest) // Usa QualifiedClassName: 'Unit1.TIntegrationTest'
.UseWorld<TDbWorld>Filtrar por categoría:
MiApp.exe -f "Cat:auth" # Features con categoría 'auth'
MiApp.exe -f "Cat:Integration" # Coincide con 'TIntegrationTest'💡 Las clases marcadoras son útiles para categorías que se usan en múltiples archivos: el compilador detecta errores tipográficos.
← Rules | Siguiente: Pending y NoAction →
Pending y NoAction
Pending y NoAction
🌍 Idioma: English | Español
MiniSpec proporciona dos formas de marcar steps que no tienen código ejecutable:
| Método | Semántica | Resultado | Uso típico |
|---|---|---|---|
.Pending |
“Pendiente de implementar” | Pending + detiene escenario | Work in progress |
.NoAction |
“Sin acción requerida” | Pass + continúa escenario | Steps puramente descriptivos |
Pending
Indica que un step está pendiente de implementar. El escenario cuenta como Pending y no se ejecutan los steps siguientes:
.Scenario('Feature en desarrollo')
.Given('una precondición', procedure(Ctx: TMyWorld) begin end)
.When('algo pendiente de implementar')
.Pending // Escenario Pending, no sigue
.&Then('nunca se ejecuta', procedure(Ctx: TMyWorld)
begin
// Este código no se ejecuta
end)Un step del Background funciona igual: uno .Pending, o uno sin binding, deja sin ejecutar el resto del Background y los steps del escenario, y el escenario cuenta como Pending o como Undefined, igual que si el step fuera suyo.
NoAction
Marca un step como puramente descriptivo. Pasa automáticamente y los steps siguientes sí se ejecutan:
.Scenario('Con un contexto descriptivo')
.Given('el sistema está configurado') // el entorno ya lo da
.NoAction
.When('el usuario hace login', procedure(Ctx: TMyWorld)
begin
Ctx.DoLogin; // este código SÍ se ejecuta
end)
.&Then('accede al dashboard', procedure(Ctx: TMyWorld)
begin
Expect(Ctx.IsLoggedIn).ToBeTrue; // también se ejecuta
end)El último recurso
Un escenario afirma el comportamiento de un evento, repartido en las tres fases: el Given deja el estado que el evento necesita, el When ejecuta el evento y el Then observa lo que queda. Un step sin cuerpo dice que esa pieza falta, y el escenario deja de decir qué valida.
No está prohibido —la marca existe por algo— pero su uso legítimo es raro, y rarísimo en un When y en un Then, así que se justifica al usarla en vez de darse por buena.
La regla que decide: un paso que no tiene nada que ejecutar casi siempre tiene algo que comprobar. Esa es su forma de check, y es lo que deja .NoAction para el residuo de verdad.
| Fase | Qué hacer cuando no hay nada que ejecutar |
|---|---|
Given |
afirmar que el entorno es el que supones |
When |
en una acción implícita, esperar a que haya ocurrido y afirmar que ocurrió |
Then |
aquí no cabe nada: un escenario que no observa nada no afirma nada |
Así que antes de escribirlo, dos preguntas: ¿hay algo que ejecutar aquí? y ¿hay algo que comprobar aquí? .NoAction es la respuesta cuando las dos son que no.
La acción implícita
Hay eventos que produce el sistema y no el escenario: una tarea en segundo plano que corre sola, un temporizador, algo que ya ocurrió mientras se inicializaba una unit. El When nombra el evento aunque el test no lo haya causado.
.When('acción implícita').NoActionDos preguntas antes de aceptarlo, porque la mayoría de los steps que parecen implícitos no lo son:
- ¿Puedo dispararlo? Entonces dispáralo en el
When, y este no es el caso. - ¿Puedo esperarlo? Entonces la espera es el cuerpo del step:
.When('la tarea en segundo plano ya corrió',
procedure(W: TMyWorld)
begin
Expect(W.WaitForTask(5000)).ToBeTrue;
end)⚠️ Un When que espera quita la carrera. Sin él, el Then puede correr antes de que la tarea termine, y entonces el escenario pasa o falla según lo cargada que esté la máquina — que es fragilidad, no una cuestión de forma.
⚠️ En todo lo demás, un When sin acción imprime ✓ igual, así que el informe declara un evento que no ocurrió y se lee como cubierto. Si el escenario no tiene evento ninguno, no tiene comportamiento que validar: omitir el When en vez de desactivarlo es la misma situación con otro rótulo. Donde suele aparecer el evento es en el Given, que lo ejecutó antes de tiempo, o dentro del Then, que lo ejecuta mientras comprueba.
⚠️ Nada impide ponerlo en un Then, y nada avisa. El step pasa a ser un cuerpo que no hace nada y que pasa, así que el escenario se queda con un Then que no verifica nada y sigue en verde — que es peor que un fallo, porque parece cobertura.
.&Then('verificación').NoAction // compila, se ejecuta, no verifica nadaEl Given que no tiene nada que construir
El caso legítimo existe y es estrecho: el entorno ya tiene el estado, así que no hay nada que preparar. Aun así, comprobar es mejor que no hacer nada — afirma que el entorno es el que supones:
.Given('el sistema está configurado',
procedure(W: TMyWorld)
begin
Expect(W.Environment.IsReady).ToBeTrue;
end)Un Given que afirma su punto de partida convierte un fallo de entorno en un rojo que dice el entorno no era el que suponías, en vez de un Then que falla y te manda a buscar donde no es.
Dónde cae
.NoAction sustituye el cuerpo del último step declarado, sea del tipo que sea:
.Given('contexto descriptivo').NoAction← Vocabulario Gherkin | Siguiente: Hooks →
Hooks (Before/After)
Before / After Hooks
🌍 Idioma: English | Español
Los hooks Before y After ejecutan código una sola vez por Feature, a diferencia de Background que ejecuta por cada scenario:
Feature('Database Tests')
.UseWorld<TDbWorld>
.Before('Start test database', procedure
begin
GTestDb := TTestDatabase.Create;
GTestDb.Start;
end)
.After('Stop test database', procedure
begin
GTestDb.Free;
end)
.Background
.Given('a fresh transaction', procedure(W: TDbWorld)
begin
W.Tx := GTestDb.BeginTransaction; // Se ejecuta por cada scenario
end)
.Scenario('Insert record')
// ...Comparación
| Elemento | Ejecuta | Uso típico |
|---|---|---|
Before |
Una vez antes de todos los scenarios | Iniciar servidor, crear BD |
After |
Una vez después de todos los scenarios | Liberar recursos costosos |
Background |
Antes de cada scenario | Preparar datos del test |
Nota: Los hooks no reciben World porque se ejecutan antes de que exista cualquier instancia.
Hooks de la suite
Before y After declarados sobre MiniSpec, en el programa que ejecuta la suite, se ejecutan una vez antes de la primera feature y una vez después de la última, también cuando alguna feature falla:
MiniSpec
.Before('Arrancar el servidor compartido', procedure
begin
// ...
end)
.After('Parar el servidor compartido', procedure
begin
// ...
end)
.Run;Cuando un hook lanza
En una feature, un Before que lanza detiene los Before que le siguen, y los escenarios de la feature no se ejecutan. La feature cuenta como un fallo, y el informe muestra el mensaje bajo su cabecera. Sus After se ejecutan igual.
En la suite, todos los Before se ejecutan aunque uno lance, y entonces no se ejecuta ninguna feature. Todos los After se ejecutan también. Cada hook que lanza cuenta como un fallo, su mensaje sale al principio del informe de consola, y la ejecución sale con 1.
← Pending y NoAction | Siguiente: Configuración →
Configuración Global
Configuración Global
🌍 Idioma: English | Español
MiniSpec: Configuración Global de la Suite
La función MiniSpec devuelve la instancia global de la suite de tests. Permite configurar opciones globales y hooks a nivel de suite (antes/después de todas las features):
program MySpecs;
{$APPTYPE CONSOLE}
uses
Daf.MiniSpec,
// ... features ...
begin
MiniSpec
.Category('Mi Suite de Tests') // Título de la suite
.Before('Inicializar entorno', procedure
begin
// Se ejecuta UNA vez, antes de todas las features
DatabaseTestServer.Start;
end)
.After('Limpiar entorno', procedure
begin
// Se ejecuta UNA vez, después de todas las features
DatabaseTestServer.Stop;
end);
MiniSpec.Run;
end.UseSuiteContext: Estado Global
Similar a UseFeatureContext pero a nivel de toda la suite. El contexto se comparte entre todas las features:
type
TGlobalContext = class
public
TestServer: TTestServer;
SharedConfig: TConfig;
end;
begin
MiniSpec
.UseSuiteContext<TGlobalContext>
.Before('Start server', procedure
begin
// TGlobalContext ya está creado y disponible via inyección
end);
MiniSpec.Run;
end.Los World de cada feature pueden inyectar este contexto con [MiniInject].
Opciones de Ejecución
MiniSpec
.DryRun(True) // Lista escenarios sin ejecutarlos
.Pause(True) // Espera tecla al finalizar
.Reporter('live:port=9000'); // Configura reporter programáticamenteSpecContext: Acceso al Contexto de Ejecución
Para casos avanzados donde necesitas acceder al contexto de ejecución (step actual, scenario, feature), MiniSpec proporciona la función global SpecContext.
type
TMyWorld = class // Clase simple, sin herencia especial requerida
public
Value: Integer;
end;
// En los steps, acceder al contexto via SpecContext:
.When('ejecuto algo', procedure(World: TMyWorld)
begin
// Acceso al step actual
WriteLn('Step: ', SpecContext.Step.Description);
// Navegación directa a padres
WriteLn('Scenario: ', SpecContext.Scenario.Description);
WriteLn('Feature: ', SpecContext.Feature.Title);
// Dentro de un escenario Rule siempre está asignada: uno declarado sin Rule explícita
// pertenece a una Rule implícita, cuya Description está vacía. Solo es nil fuera de un
// escenario — en un hook de suite o de feature.
if SpecContext.Rule.Description <> '' then
WriteLn('Rule: ', SpecContext.Rule.Description);
// DataTable del step actual (nil si no tiene)
if Assigned(SpecContext.DataTable) then
WriteLn('Rows: ', SpecContext.DataTable.RowCount);
end)ISpecContext proporciona
| Propiedad | Descripción |
|---|---|
Suite |
La Suite contenedora |
Feature |
La Feature contenedora |
Rule |
La Rule contenedora. Siempre asignada dentro de un escenario —implícita, con Description vacía, si no se declaró ninguna—; nil fuera de un escenario |
Scenario |
El scenario (o Example) actual |
Step |
El step que se está ejecutando |
DataTable |
La tabla de datos del step (nil si no tiene) |
SuiteContext |
El contexto de suite, o nil si no se declaró ninguno |
FeatureContext |
El contexto de feature, o nil si no se declaró ninguno |
ScenarioContext |
El World del escenario en curso |
Injector |
El inyector de esta ejecución, para resolver un contexto por tipo |
FeatureContext: Recursos Compartidos entre Escenarios
A diferencia del World (que se crea nuevo para cada escenario), el FeatureContext permite compartir recursos entre todos los escenarios de una Feature. Es útil para recursos costosos de crear (una conexión, una caché de solo lectura). No es para datos de escenario: si un escenario lee lo que dejó otro, la Feature depende del orden de ejecución; cada escenario crea sus datos en su Given:
type
TSharedContext = class
public
Connection: TDbConnection; // recurso costoso, de solo lectura para los escenarios
end;
TScenarioWorld = class
private
[MiniInject] FShared: TSharedContext; // Inyectado automáticamente
public
LocalData: string;
Result: string;
property Shared: TSharedContext read FShared;
end;
Feature('Database operations')
.UseFeatureContext<TSharedContext> // Crea UNA instancia para toda la Feature
.UseWorld<TScenarioWorld> // Cada escenario recibe su propio World
.Scenario('First query')
.When('query data', procedure(W: TScenarioWorld)
begin
// W.Shared apunta al mismo TSharedContext: la misma conexión
W.Result := W.Shared.Connection.Query('SELECT COUNT(*) FROM users');
end)
.Scenario('Second query')
.When('query other data', procedure(W: TScenarioWorld)
begin
// Reutiliza la conexión; no lee nada que haya dejado el escenario anterior
W.Result := W.Shared.Connection.Query('SELECT COUNT(*) FROM orders');
end)Ciclo de Vida
- El FeatureContext se crea al inicio de la Feature
- Se destruye al finalizar la Feature
- Cada ScenarioWorld recibe la inyección del mismo FeatureContext
← Hooks | Siguiente: Inyección de Dependencias →
Inyección de Dependencias
Inyección de Dependencias con [MiniInject]
🌍 Idioma: English | Español
MiniSpec incluye un sistema ligero de inyección de dependencias para campos y propiedades marcados con [MiniInject].
Uso Básico
Inyección automática del FeatureContext:
uses
Daf.MiniSpec; // MiniInject viaja en la facade
type
TFeatureContext = class
public
SharedValue: Integer;
end;
TWorld = class
private
[MiniInject] FCtx: TFeatureContext; // Inyectado automáticamente
public
property Ctx: TFeatureContext read FCtx;
end;
Feature('...')
.UseFeatureContext<TFeatureContext> // Registra TFeatureContext en el Injector
.UseWorld<TWorld> // Al crear World, inyecta FCtxDónde puede ir la marca
Sobre un campo o sobre una propiedad. Un campo se alcanza sea cual sea su visibilidad, strict private incluido; una propiedad solo si es public o published, que es lo que emite la RTTI por defecto de Delphi para propiedades. Marcar una propiedad privada no hace nada y no da error.
TInjectorService exige además que la propiedad sea escribible. Un campo siempre lo es.
Los tres contextos, y cuál gana
Un miembro [MiniInject] recibe cualquiera de los tres contextos vivos mientras corre un step: el World del propio escenario, el contexto de feature declarado con UseFeatureContext<T> y el contexto de suite declarado con UseSuiteContext<T>.
Cuando más de uno satisface el tipo pedido, el que gana está declarado:
- Una coincidencia exacta de tipo, mirando escenario, luego feature y luego suite.
- Si no la hay, un descendiente del tipo pedido, en ese mismo orden.
Así, una propiedad declarada con una clase base de la que descienden dos contextos recibe el más cercano al step, y una declarada con el tipo exacto del contexto de suite recibe ese aunque un contexto de feature descienda de él.
El inyector es de la ejecución: lo que contiene son los contextos de la ejecución en curso, y no le sobrevive. Una ejecución anidada no ve los contextos de la que la lanzó.
API del TInjectorService
| Método | Descripción |
|---|---|
Register(Scope, Instance) |
Publica el contexto de un ámbito: csScenario, csFeature, csSuite |
Unregister(Scope) |
Vacía un ámbito |
Resolve(AClass) |
Obtiene el contexto vivo compatible con esa clase, por la precedencia de arriba |
Resolve<T> |
Versión genérica de Resolve |
InjectInto(Target) |
Inyecta contextos en campos y propiedades marcados con [MiniInject] |
Clear |
Vacía los tres ámbitos |
El runner llama a Register y Unregister por su cuenta según se crea y se destruye cada contexto. No hay registro de servicios sueltos: el colaborador que necesite un escenario se construye en el World, o en el contexto de feature o de suite si hay que compartirlo.
Errores de Inyección
Si un miembro marcado con [MiniInject] no puede ser inyectado, se lanza EInjectionError:
- Propiedad no es de tipo clase
- Propiedad no tiene setter
- No hay servicio registrado compatible con el tipo
← Configuración | Siguiente: Reporters →
Test Doubles
MiniSpec Doubles — Test Doubles para Delphi
🌍 Idioma: English | Español
Una API fluida y minimalista para test doubles que mantiene la elegancia de MiniSpec. Crea stubs, mocks y spies con una sintaxis encadenable e intuitiva.
Contenido
Inicio Rápido
uses
Daf.MiniSpec,
Daf.MiniSpec.Doubles;
type
IUserRepository = interface
function FindById(Id: Integer): IUser;
procedure Save(User: IUser);
end;
initialization
Feature('Servicio de Usuario')
.UseWorld<TUserWorld>
.Scenario('Actualizar email de usuario')
.Given('un repositorio de usuarios', procedure(W: TUserWorld)
begin
W.Repo := Stub<IUserRepository>
.Setup('FindById').WithArgs([42]).Returns(TUser.Create('John'))
.Instance;
end)
.&And('un servicio de usuarios', procedure(W: TUserWorld)
begin
W.Service := TUserService.Create(W.Repo); // montar el SUT es preparación, no el evento
end)
.When('actualizo el email', procedure(W: TUserWorld)
begin
W.Service.UpdateEmail(42, 'john@new.com');
end)
.&Then('el usuario es guardado', procedure(W: TUserWorld)
begin
Expect(W.Repo.WasCalled('Save')).ToBeTrue;
end)Stubs
Los stubs proporcionan respuestas predefinidas a llamadas de métodos. Úsalos cuando necesites controlar lo que devuelven los colaboradores.
Stub Básico
var Calculator := Stub<ICalculator>
.Setup('Add').Returns(42)
.Instance;
// Calculator.Add(cualquier cosa) devuelve 42Retornos Condicionales con Argumentos
var Service := Stub<IUserService>
.Setup('FindById').WithArgs([1]).Returns(User1)
.Setup('FindById').WithArgs([2]).Returns(User2)
.Setup('FindById').Raises(Exception.Create('FindById sin Setup para estos argumentos')) // Por defecto: que falle
.Instance;
// Service.FindById(1) → User1
// Service.FindById(2) → User2
// Service.FindById(999) → lanza: el escenario no dijo qué devolver⚠️ Un Stub sin Setup que case devuelve el cero del tipo (False, nil, 0, ''), sin avisar. Ese cero puede ser justo un valor «bueno» y dar un falso verde: la prueba pasa sin que el escenario haya dicho nunca qué debía devolver el doble. Por eso el ejemplo cierra con un Setup por defecto que lanza una excepción en vez de .Returns(nil): un doble al que el escenario no ha dicho qué devolver tiene que fallar. Haz lo mismo en tus stubs cuando el cero del tipo pudiera pasar por un resultado válido, o marca el doble como estricto.
Modo Estricto
.Strict hace que un Stub o un Mock lance EUnconfiguredCall cuando se llama a una función y ningún Setup casa, en vez de devolver el cero del tipo. El mensaje nombra la interfaz, el método y los argumentos:
var Service := Stub<IUserService>.New.Strict
.Setup('FindByName').WithArgs([Arg.NotNull]).Returns(7)
.Instance;
// Service.FindByName('ana') devuelve 7
// Service.FindByName('') lanza EUnconfiguredCall: no Setup matches IUserService.FindByName('')- Cuenta cualquier
Setupque case, configure lo que configure (Returns,Raises,Executes). UnSetupsinWithArgsacepta todas las llamadas. - Los procedimientos no se ven afectados: la llamada a un procedimiento sin
Setupse registra y no lanza. - La llamada que lanza se registra antes, así que
CallsToyWasCalledla cuentan. EUnconfiguredCalldesciende deEDoubleError.- Llama a
.Strictjusto después deNew, en elGiven. Solo afecta a las llamadas posteriores.
⚠️ Si el código bajo prueba captura todas las excepciones, también se traga EUnconfiguredCall y el escenario puede pasar igualmente. Sin .Strict, un doble devuelve el cero del tipo.
Lanzar Excepciones
var Service := Stub<IPaymentService>
.Setup('Charge').WithArgs([0]).Raises(EInvalidAmount.Create('El monto debe ser positivo'))
.Setup('Charge').Returns(True)
.Instance;
// Service.Charge(0) lanza EInvalidAmount
// Service.Charge(100) devuelve TrueComportamiento Personalizado
var Counter := 0;
var Service := Stub<ICounter>
.Setup('Increment').Executes(
procedure(Args: TArray<TValue>)
begin
Inc(Counter);
end)
.Instance;Verificar Llamadas
var Stub := Stub<ILogger>
.Setup('Log').Returns(nil)
.Instance;
// ... usar el stub ...
if Stub.WasCalled('Log') then
WriteLn('Log fue llamado ', Stub.CallsTo('Log'), ' veces');
// Obtener detalles de la última llamada
var LastCall := Stub.LastCallTo('Log');
WriteLn('Último mensaje: ', LastCall.Args[0].AsString);Mocks
Los mocks verifican interacciones. Úsalos cuando el comportamiento que estás probando trata sobre cómo tu código interactúa con sus colaboradores.
Expectativas Básicas
var Logger := Mock<ILogger>
.Expects('Log').Once
.Expects('Flush').AtLeastOnce
.Instance;
// ... usar logger en tu código ...
Logger.Verify; // Lanza EExpectationFailed si las expectativas no se cumplenTipos de Expectativas
.Expects('Method').Never // No debe ser llamado
.Expects('Method').Once // Exactamente una vez
.Expects('Method').Twice // Exactamente dos veces
.Expects('Method').AtLeastOnce // Una o más veces
.Expects('Method').AtMostOnce // Cero o una vez
.Expects('Method').Exactly(5) // Exactamente N vecesCombinar Setup y Expectativas
var PaymentGateway := Mock<IPaymentGateway>
// Configurar valores de retorno
.Setup('Authorize').Returns(True)
.Setup('Capture').Returns('TXN-123')
// Definir expectativas
.Expects('Authorize').Once
.Expects('Capture').Once
.Expects('Refund').Never
.Instance;
// ... procesar pago ...
PaymentGateway.Verify;Expectativas por Argumento
var Notifier := Mock<INotifier>
.Expects('Send').WithArgs(['admin@example.com']).Once
.Expects('Send').WithArgs(['user@example.com']).Once
.Instance;Mocks Estrictos
Mock<T>.New.Strict se comporta como un stub estricto. En un mock, Expects no cuenta como configuración: una función con Expects y sin Setup que case lanza EUnconfiguredCall, porque Expects dice cuántas veces se llama, no qué devuelve. Añade el Setup:
var Calc := Mock<ICalculator>.New.Strict
.Setup('Add').Returns(100)
.Expects('Add').Once
.Instance;Spies
Los spies envuelven instancias reales, rastreando llamadas mientras preservan el comportamiento original.
var RealCalc := TCalculator.Create as ICalculator;
var Spy := SpyOn<ICalculator>(RealCalc)
.TrackAll
.Instance;
// Usar spy - las llamadas van a la implementación real
var Result := Spy.Add(2, 3); // Devuelve 5 (comportamiento real)
// Verificar qué pasó
Expect(Spy.CallsTo('Add')).ToEqual(1);
Expect(Spy.LastCallTo('Add').Args[0].AsInteger).ToEqual(2);Rastreo Selectivo
var Spy := SpyOn<IService>(RealService)
.Track('MetodoImportante')
.Track('OtroMetodo')
.Instance;Matchers de Argumentos
Para coincidencia flexible de argumentos en stubs y mocks.
Matchers Incluidos
uses Daf.MiniSpec.Doubles;
// Coincidir cualquier valor
.Setup('Process').WithArgs([Arg.Any]).Returns(True)
// Coincidir no-nulo/no-vacío
.Setup('Save').WithArgs([Arg.NotNull]).Returns(True)
// Coincidir string que contiene substring
.Setup('Log').WithArgs([Arg.Contains('error')]).Returns(nil)
// Coincidir lo que acepte un predicado
.Setup('Listen').WithArgs([Arg.Matching<Integer>(
function(Port: Integer): Boolean
begin
Result := (Port >= 1) and (Port <= 65535);
end)]).Returns(True)Arg.Matching<T> recibe la condición misma. El valor llega al predicado ya convertido a T, así que un rango es uno de estos y no un matcher aparte. Un argumento de otro tipo no casa, y al predicado no se le pregunta.
Ejemplo con Matchers
Los setups se resuelven del último declarado hacia atrás, y uno con matchers gana a uno sin ellos. Así que el más estrecho va el último: declararlo primero no lo protege de uno más amplio declarado después.
var EmailService := Stub<IEmailService>
.Setup('Send')
.WithArgs([Arg.NotNull, Arg.NotNull])
.Returns(True)
.Setup('Send')
.WithArgs([Arg.Contains('@admin'), Arg.Any])
.Raises(ESecurityException.Create('No se puede enviar email a admins'))
.Instance;
// EmailService.Send('admin@co.com', 'Hola') → lanza excepción
// EmailService.Send('user@co.com', 'Hola') → True
// EmailService.Send('', 'Hola') → False: no casó ninguno, así que vuelve el cero del tipo
// (ojo: un False silencioso puede ser un falso verde; añade un Setup por defecto que lance)Buenas Prácticas
1. Preferir Stubs sobre Mocks
Usa stubs cuando solo necesites proporcionar datos de prueba. Reserva los mocks para cuando la interacción en sí es lo que estás probando.
// ✅ Bien: Stub para datos
var Repo := Stub<IRepository>
.Setup('Find').Returns(TestData)
.Instance;
// ✅ Bien: Mock cuando la interacción importa
var Notifier := Mock<INotifier>
.Expects('SendAlert').Once
.Instance;2. Mantener los Doubles Simples
No sobre-configurar. Configura solo lo que la prueba necesita.
// ❌ Demasiada configuración
var Service := Stub<IService>
.Setup('Method1').Returns(...)
.Setup('Method2').Returns(...)
.Setup('Method3').Returns(...)
.Setup('Method4').Returns(...)
.Instance;
// ✅ Solo lo que esta prueba necesita
var Service := Stub<IService>
.Setup('GetUser').Returns(TestUser)
.Instance;3. Un Mock Por Test
Evita múltiples mocks en un solo escenario. Si necesitas muchos, considera si tu diseño necesita refactorización.
4. Verificar al Final
Llama a Verify en tu paso Then, después de que todas las interacciones hayan ocurrido.
.&Then('las notificaciones fueron enviadas', procedure(W: TWorld)
begin
W.NotifierMock.Verify;
end)5. Usar Fallos Descriptivos
Los fallos de verificación son descriptivos:
Mock verification failed:
"SendEmail" expected once, but called 0 times
"Log" expected never, but called 3 times
Integración con MiniSpec
Los doubles funcionan perfectamente con el patrón World de MiniSpec:
type
TOrderWorld = class
Repo: IStubSetup<IOrderRepository>;
Notifier: IMockExpect<INotifier>;
Service: TOrderService;
destructor Destroy; override;
end;
destructor TOrderWorld.Destroy;
begin
Service.Free;
inherited;
end;
initialization
Feature('Procesamiento de Pedidos')
.UseWorld<TOrderWorld>
.Background
.Given('un repositorio stub', procedure(W: TOrderWorld)
begin
W.Repo := Stub<IOrderRepository>
.Setup('FindById').Returns(TOrder.Create(1, 'Pendiente'));
end)
.&And('un notificador mock', procedure(W: TOrderWorld)
begin
W.Notifier := Mock<INotifier>
.Setup('Notify').Returns(nil);
end)
.Scenario('Completar un pedido envía notificación')
.Given('el servicio', procedure(W: TOrderWorld)
begin
W.Notifier.Expects('Notify').Once;
W.Service := TOrderService.Create(W.Repo.Instance, W.Notifier.Instance);
end)
.When('completo el pedido #1', procedure(W: TOrderWorld)
begin
W.Service.Complete(1);
end)
.&Then('el cliente es notificado', procedure(W: TOrderWorld)
begin
W.Notifier.Verify;
end)Requisitos
- Delphi 12 Athens o posterior
- Interfaces con RTTI habilitado (
{$M+}o{$TYPEINFO ON})
Parte de MiniSpec — Framework BDD para Delphi
Reporters
Reporters
🌍 Idioma: English | Español
Sintaxis: -r <nombre>:<opcion1>=<valor>,<opcion2>=<valor>,...
| Reporter | Opciones | Ejemplo |
|---|---|---|
console |
(ninguna) | -r console |
json |
output=<file> |
-r json:output=report.json |
junit |
output=<file> |
-r junit:output=results.xml |
gherkin |
output=<dir> |
-r gherkin:output=features/ |
live |
port=<num>, wait=<ms> |
-r live:port=8080,wait=5000 |
Múltiples Reporters
Puedes usar varios reporters en la misma ejecución repitiendo la opción -r:
# Consola + JUnit para CI + JSON para análisis
MiApp.exe -r console -r junit:output=results.xml -r json:output=report.jsonTodos los reporters reciben los mismos eventos y generan su salida simultáneamente.
Filtrado y supresión de features
Cuando se usa -f, los reporters solo incluyen features que tienen al menos un escenario ejecutado. Las features completamente filtradas se omiten silenciosamente — de la consola, del XML JUnit, del JSON y de los ficheros Gherkin.
JUnit Reporter
Genera XML en formato JUnit para integración CI/CD. Compatible con GitHub Actions, GitLab CI, Jenkins, Azure DevOps.
Cada escenario que se ejecutó, y cada fila de un Scenario Outline, es un caso. Uno fallado o Undefined lleva un <failure>, y uno Pending un <skipped>, cuyo mensaje nombra el step que lo dejó así. Un escenario que no se ejecutó, como uno que el filtro dejó fuera, no se lista.
Cada hook que lanzó, de la suite o de una feature, es un caso con un <failure> que lleva la descripción del hook y su mensaje. Los de una feature van en su bloque, y los de la suite en un bloque con el nombre de la suite. Los atributos cuentan los casos que se listan: failures recoge los escenarios fallados y Undefined y los hooks, y skipped los Pending. --allow-undefined y --strict-pending cambian el código de salida, no el informe. Cucumber, desde la 6.11, da un escenario Pending como fallo; MiniSpec lo mantiene como omitido, igual que su código de salida.
JSON Reporter
Cada escenario, cada fila de un Scenario Outline y cada Background lleva un status: pass, fail, pending, undefined, o skip si no se ejecutó. Cada step lleva un status con los nombres del formateador JSON de Cucumber, passed, failed, skipped, pending y undefined, y success, verdadero solo para un step que pasó. La raíz lleva los totales: passCount, failCount, skipCount, pendingCount y undefinedCount.
Live Reporter
Por defecto espera 3 segundos para conexión del navegador. Usa wait=0 para deshabilitar.
Archivo de Configuración
MiniSpec crea MiniSpec.ini en el directorio del ejecutable si no existe:
[minispec]
reporters=console,live
filter=@unit
pause=true
[reporter.live]
port=8080
wait=3000
[reporter.junit]
output=results.xmlLa clave reporters acepta múltiples nombres de reporter separados por comas. Cada reporter puede tener su propia sección [reporter.<nombre>] para opciones.
Los reporters declarados en secciones [reporter.X] se registran automáticamente aunque no estén listados en la clave reporters. Por ejemplo, lo siguiente equivale a reporters=console,live:
[minispec]
reporters=console
[reporter.live]
port=9000Compatibilidad: la clave antigua
reporter=(singular) sigue siendo leída.
Las opciones de línea de comandos tienen prioridad sobre el archivo.
← Inyección de Dependencias | Siguiente: Línea de Comandos →
Línea de Comandos
Línea de Comandos
🌍 Idioma: English | Español
| Opción | Descripción |
|---|---|
-h, --help |
Muestra ayuda |
-f, --filter <expr> |
Filtra escenarios (ver Tags y Filtrado) |
-t, --tags |
Lista todos los tags con conteos |
-q, --query <expr> |
Muestra escenarios que coinciden (sin ejecutar) |
-r, --reporter <spec> |
Reporter con opciones (ver Reporters) |
--pause |
Espera tecla al finalizar |
--dry-run |
Lista escenarios sin ejecutarlos |
--stacktrace |
Muestra stack trace completo en errores |
--strict-pending |
Trata specs pendientes como fallos (exit code 1) |
--allow-undefined |
Los steps undefined no fijan el exit code (se siguen contando e informando) |
💡
--stacktracerequiere una librería de stack traces (JclDebug, MadExcept, EurekaLog).
Qué fija el exit code
Una spec fallada y un step undefined lo ponen a 1; los pendientes no, salvo que lo pidas con --strict-pending. Un step undefined es uno cuya descripción no encontró binding —una omisión no intencional, no trabajo en curso—, así que rompe por defecto: si no, renombrar una step definition dejaría la suite verde mientras ese step no se ejecuta.
--allow-undefined es para una suite que marca un step como undefined a propósito, que es la única forma de comprobar que los steps siguientes se saltan. Libera el exit code y nada más: la línea de resumen sigue contando los undefined y el reporter sigue marcándolos. Una suite que lo necesita puede declararlo ella misma con MiniSpec.AllowUndefined(True), en vez de depender de que quien la lance pase la bandera.
Ejemplos
# Ayuda
MiApp.exe -h
# Ejecutar solo tests unitarios
MiApp.exe -f "@unit"
# Ver qué escenarios coinciden sin ejecutar
MiApp.exe -q "@integration"
# Listar todos los tags
MiApp.exe -t
# Ejecutar con JUnit output para CI
MiApp.exe -r junit:output=results.xml
# Dashboard en tiempo real
MiApp.exe -r live:port=8080
# Múltiples reporters simultáneamente
MiApp.exe -r console -r junit:output=results.xml -r json:output=report.jsonArchivo de Configuración
Si no existe un archivo MiniSpec.ini en el directorio del ejecutable, MiniSpec crea uno con opciones por defecto en la primera ejecución. En ejecuciones posteriores, las opciones se cargan de este archivo.
Las opciones de línea de comandos siempre tienen prioridad sobre el archivo. Ver Reporters para la sintaxis completa del INI.
[minispec]
reporters=console,live
filter=@unit
pause=true
[reporter.live]
port=8080← Reporters | Siguiente: Ejecuciones anidadas →
Ejecuciones anidadas
Ejecuciones anidadas
🌍 Idioma: English | Español
Un TSpecRun es una ejecución autocontenida: posee su suite, su contexto, su inyector, sus opciones y su runner con sus listeners. Se puede lanzar dentro de un escenario en marcha sin alterar los recuentos de la ejecución que lo contiene y sin escribir una línea en su informe.
Eso es lo que hace que las reglas del runner se puedan comprobar con specs en vez de a mano.
Montar una y ejecutarla
uses Daf.MiniSpec, Daf.MiniSpec.Types, Daf.MiniSpec.Runner;
type
TToyWorld = class
public
Touched: Boolean;
end;
var Run := TSpecRun.Create('Nested');
try
Feature('A toy feature that fails', Run.Suite)
.UseWorld<TToyWorld>
.Scenario('It fails on purpose')
.&Then('one is not two', procedure(W: TToyWorld)
begin
Expect(1).ToEqual(2);
end);
Run.Execute;
Expect(Run.FailCount).ToEqual(1); // la anidada contó su propio fallo
Expect(Run.ExitCode).ToEqual(1); // y dice qué código le corresponde a sus recuentos
Expect(ExitCode).ToEqual(0); // el del proceso queda intacto
finally
Run.Free;
end;TSpecRun es una clase normal, así que la libera quien la creó — habitualmente el World del escenario que la ejecuta.
Feature(Descripción, Suite) es la sobrecarga que declara la feature en la suite que se le indica en vez de en la del proceso. Es la única vía por la que un árbol de features llega a una ejecución anidada: el Feature de un argumento declara siempre en la suite del proceso.
Lo que se queda fuera de una ejecución anidada
El reparto con MiniSpec sigue una sola regla: lo que solo tiene sentido una vez por proceso se queda en la fachada — los argumentos de línea de comandos, el .ini, el banner y el código de salida del proceso.
Run.ExitCode es el código que le corresponde a esa ejecución, como función de sus propios recuentos. Quien la lanzó decide qué hacer con él; solo la ejecución raíz lo lleva al proceso.
Informes
Una ejecución anidada no imprime nada hasta que se le da un destino:
Run.AddListener(Listener);Sin ningún listener no hay salida, y eso es lo que permite anidar una ejecución dentro de un escenario sin ensuciar su informe. Los destinos disponibles están en Reporters.
Su propio contexto de suite
Run.UseSuiteContext<TMyContext>;Fija cómo se construye el contexto de suite de esta ejecución. Una excepción que escape de ese constructor es el único caso que escapa de Run.Execute: lo que revienta dentro de un escenario lo atrapa el propio runner y lo cuenta como fallo.
Qué informa una ejecución anidada
| Miembro | Responde |
|---|---|
Suite |
La suite de esta ejecución — lo que toma Feature(Descripción, Suite) |
Runner |
Su runner, con sus propios listeners |
Options |
Las opciones de esta ejecución, independientes de las del proceso |
Injector |
Su inyector |
PassCount FailCount SkipCount PendingCount UndefinedCount TotalCount |
Sus propios recuentos |
FeatureCount |
Features declaradas en su suite |
ElapsedMs |
Lo que tardó esta ejecución |
ExitCode |
El código que le corresponde a sus recuentos |
← Línea de Comandos | Volver a la Guía →
Testing Patterns
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.
⚠️ Los nombres de fichero de esta guía son una sugerencia para tu proyecto, no la convención del propio DAF. Aquí el sufijo lleva el nivel del test: .Spec.pas para una clase, .Feat.pas para una feature. El árbol de DAF llama [Feature].Feat.pas a todas sus specs y [Feature].Steps.pas a sus ficheros de pasos, sea cual sea el nivel.
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
- Conclusión
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.
Cada nivel es el criterio de aceptación de un contrato con su consumidor: el producto, con quien lo usa (lenguaje de negocio); una API, con otros sistemas (lenguaje técnico); una clase, con los desarrolladores que la usan (su API pública). La pirámide es un criterio de coste (velocidad, fragilidad), no de qué nivel “es” criterio de aceptación.
E2E no es un nivel más, sino la fidelidad con que se ejecutan las specs: las mismas specs, contra infraestructura real en vez de con dobles. Va en una suite aparte, con su coste a la vista. En esta guía “E2E” nombra el nivel de producto, que es donde ese coste suele notarse.
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
- Son el criterio de aceptación del producto frente a quien lo usa (cada nivel tiene el suyo: ver la pirámide)
- Se pueden ejecutar con dobles o, en una suite E2E aparte, contra infraestructura real (base de datos, servicios externos); esa fidelidad es lo que los hace lentos, no el nivel
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, Rule = Método/Responsabilidad, Scenario = Ejemplo
unit TStringBuilder.Spec;
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')
.When('construyo un StringBuilder', procedure(W: TStringBuilderWorld)
begin
W.SUT.Free; // El evento es la construcción: reemplaza el SUT del Background
W.SUT := TStringBuilder.Create;
end)
.&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, Rule = Obligación de un endpoint, Scenario = Ejemplo
unit API.Users.Spec;
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)
.&And('un servidor sin datos de escenarios anteriores', procedure(W: TApiWorld)
begin
TestServer.CleanupTestData; // cada escenario parte limpio: no depende del orden
end)
.Rule('GET /users - Listar usuarios')
.Scenario('Lista vacía cuando no hay usuarios')
.Given('un servidor sin usuarios', procedure(W: TApiWorld)
begin
TestServer.CleanupTestData;
end)
.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')
.Given('no existe el usuario 999999', procedure(W: TApiWorld)
begin
// El Background dejó el servidor sin usuarios; lo afirmamos
Expect(W.Client.Get('http://localhost:3000/api/v1/users/999999').StatusCode).ToEqual(404);
end)
.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.Convenciones para Tests de Integración
| Elemento | Convención | Ejemplo |
|---|---|---|
| Feature | Endpoint o recurso | API: /users, GraphQL: User |
| Rule | Método HTTP + ruta | GET /users, POST /users |
| Scenario | Caso de uso concreto | Crear usuario con datos válidos |
| Tag | @integration o @api |
API: /users @integration @api |
Usando FeatureContext para Recursos Compartidos
En tests de API, FeatureContext sirve para compartir recursos (configuración, token, conexión), no datos: un escenario no debe leer lo que creó otro, porque dependería del orden. Cada escenario crea sus datos en su Given:
type
TApiContext = class
public
BaseUrl: string;
AuthToken: string;
CreatedIds: TList<string>;
constructor Create;
destructor Destroy; override;
end;
TApiWorld = class
private
[MiniInject] 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 - Levanta un TestServer local en el Before
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 infraestructura real (BD, servicios)? | Solo en la suite E2E | Solo en la suite E2E | |
| ¿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 o responsabilidad
- Integration tests: Feature = Recurso, Rule = la obligación de un endpoint
- Producto (E2E): Feature = Capacidad, Rule = Regla de negocio
En los tres, la Rule es el criterio de aceptación del contrato con su consumidor y el Scenario es un ejemplo suyo.
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.