Pruebas unitarias de Dart

Las pruebas unitarias son el medio fundamental para garantizar la calidad del código.

Verifican que el comportamiento de cada unidad funcional mínima (normalmente una función o método) sea el esperado.

Este capítulo presenta el uso del paquete test de Dart, la escritura de casos de prueba con test(), las aserciones con expect y las pruebas agrupadas con group().


Instalación y configuración del paquete test

Dart ofrece oficialmente el paquete test para escribir y ejecutar pruebas.

Primero añade la dependencia en pubspec.yaml:

# 文件路径:pubspec.yaml
dev_dependencies:
  test: ^1.24.0

Luego ejecuta el siguiente comando para instalarlo:

$ dart pub get

Los archivos de prueba normalmente se colocan en el directorio test/ del proyecto, y sus nombres terminan en _test.dart.

Una estructura de proyecto típica:

my_project/
├── lib/
│   └── calculator.dart       # 被测试的代码
├── test/
│   └── calculator_test.dart  # 测试文件
└── pubspec.yaml

Escribir casos de prueba con test()

La función test() es la unidad básica para escribir casos de prueba.

Recibe dos parámetros: la descripción de la prueba (una cadena) y el cuerpo de la función de prueba.

Ejemplo

Código a probar (lib/calculator.dart):

// Ruta del archivo: lib/calculator.dart
class Calculator {
  int add(int a, int b) => a + b;
  int subtract(int a, int b) => a - b;
  int multiply(int a, int b) => a * b;

  double divide(int a, int b) {
    if (b == 0) {
      throw ArgumentError('El divisor no puede ser 0');
    }
    return a / b;
  }

  bool isEven(int n) => n % 2 == 0;

  List<int> filterPositive(List<int> numbers) {
    return numbers.where((n) => n > 0).toList();
  }
}

Ejemplo

Archivo de prueba (test/calculator_test.dart):

// Ruta del archivo: test/calculator_test.dart
import 'package:test/test.dart';
import 'package:my_project/calculator.dart';

void main() {
  // Crear una instancia de Calculator para la prueba
  var calculator = Calculator();

  // Función test(): el primer parámetro es la descripción de la prueba, el segundo es la función de prueba
  test('add() suma dos números positivos', () {
    var result = calculator.add(3, 5);
    // Aserción expect(): verifica si el resultado real es igual al valor esperado
    expect(result, equals(8));
  });

  test('add() suma con números negativos', () {
    expect(calculator.add(-3, 5), equals(2));
    expect(calculator.add(-3, -5), equals(-8));
  });

  test('subtract() operación de resta', () {
    expect(calculator.subtract(10, 3), equals(7));
  });

  test('multiply() operación de multiplicación', () {
    expect(calculator.multiply(4, 5), equals(20));
    // Multiplicar por cero
    expect(calculator.multiply(100, 0), equals(0));
  });

  test('divide() división normal', () {
    expect(calculator.divide(10, 2), equals(5.0));
    expect(calculator.divide(7, 2), equals(3.5));
  });

  test('isEven() verifica si es par', () {
    expect(calculator.isEven(4), isTrue);
    expect(calculator.isEven(5), isFalse);
  });
}

Ejecutar las pruebas:

$ dart test
00:00 +6: All tests passed!

Aserciones con expect

expect() es la función más central en las pruebas; verifica si el valor real cumple una condición.

Sintaxis básica: expect(actual, matcher).

Matchers comunes

MatcherUsoEjemplo
equals(expected)Verificar que los valores sean igualesexpect(result, equals(42))
isTrue / isFalseVerificar valores booleanosexpect(flag, isTrue)
isNull / isNotNullVerificar nullexpect(value, isNull)
contains(value)Contiene un elemento (lista) o subcadena (cadena)expect(list, contains('a'))
isA()Verificar el tipoexpect(obj, isA())
throws()Verificar que se lance una excepciónexpect(() => f(), throwsException)
isNotEmptyNo vacíoexpect(list, isNotEmpty)
hasLength(n)Verificar la longitudexpect(list, hasLength(3))
greaterThan(n)Mayor queexpect(score, greaterThan(60))
closeTo(value, delta)Números de punto flotante aproximadamente igualesexpect(3.14, closeTo(3.1, 0.1))

Ejemplo

Aplicación práctica de varios Matchers:

import 'package:test/test.dart';

void main() {
  test('EJEMPLO ejemplos de varias aserciones', () {
    // Igualdad básica
    expect(2 + 2, equals(4));

    // Valores booleanos
    expect('hello'.contains('h'), isTrue);
    expect(''.isEmpty, isTrue);

    // Comprobación de null
    String? name;
    expect(name, isNull);
    name = 'EJEMPLO';
    expect(name, isNotNull);

    // Comprobación de tipo
    expect('EJEMPLO', isA<String>());
    expect(42, isA<int>());

    // Contenidos en listas/cadenas
    expect([1, 2, 3], contains(2));
    expect('Hello, EJEMPLO!', contains('EJEMPLO'));

    // Longitud
    expect([1, 2, 3], hasLength(3));
    expect('Dart', hasLength(4));

    // Comparación numérica
    expect(100, greaterThan(50));
    expect(30, lessThan(60));
    expect(75, greaterThanOrEqualTo(60));

    // Comparación de punto flotante (evita problemas de precisión)
    expect(0.1 + 0.2, closeTo(0.3, 0.001));

    // Colección no vacía
    expect([1, 2], isNotEmpty);

    // Igualdad de listas
    expect([1, 2, 3], equals([1, 2, 3]));

    // Map contiene una clave
    var user = {'name': 'ejemplo', 'age': 10};
    expect(user, containsPair('name', 'ejemplo'));
    expect(user.keys, contains('age'));
  });
}
00:00 +1: All tests passed!

Pruebas de excepciones

Ejemplo

import 'package:test/test.dart';

int divide(int a, int b) {
  if (b == 0) throw ArgumentError('El divisor no puede ser 0');
  return a ~/ b;
}

void main() {
  test('divide() debería lanzar una excepción al dividir por cero', () {
    // Verificar que se lance cualquier excepción
    expect(() => divide(10, 0), throwsException);

    // Verificar que se lance una excepción de un tipo específico
    expect(() => divide(10, 0), throwsArgumentError);

    // Verificar el mensaje de la excepción
    expect(
      () => divide(10, 0),
      throwsA(predicate((e) =>
          e is ArgumentError &&
          e.message.contains('divisor'))),
    );
  });

  test('divide() no lanza excepción en condiciones normales', () {
    expect(() => divide(10, 2), returnsNormally);
    expect(divide(10, 2), equals(5));
  });
}
00:00 +2: All tests passed!

Al probar excepciones, es necesario envolver el código que puede lanzar una excepción en una función anónima (() => code), en lugar de llamarlo directamente. Si escribes directamente expect(divide(10, 0), throwsException), divide lanzará la excepción inmediatamente y expect no tendrá oportunidad de ejecutarse.


Pruebas agrupadas con group()

group() se utiliza para organizar casos de prueba relacionados y hacer que la estructura de pruebas sea más clara.

Ejemplo

import 'package:test/test.dart';

// Función bajo prueba
class StringUtils {
  static String capitalize(String s) {
    if (s.isEmpty) return s;
    return s[0].toUpperCase() + s.substring(1);
  }

  static String reverse(String s) {
    return s.split('').reversed.join('');
  }

  static bool isPalindrome(String s) {
    var clean = s.toLowerCase().replaceAll(' ', '');
    return clean == reverse(clean);
  }

  static int countWords(String s) {
    if (s.trim().isEmpty) return 0;
    return s.trim().split(RegExp(r'\s+')).length;
  }
}

void main() {
  // Anidación de group() para organizar pruebas
  group('StringUtils', () {
    // Subgrupo
    group('capitalize()', () {
      test('Las palabras normales comienzan con mayúscula', () {
        expect(StringUtils.capitalize('hello'), equals('Hello'));
      });

      test('Las palabras ya en mayúscula no cambian', () {
        expect(StringUtils.capitalize('Hello'), equals('Hello'));
      });

      test('La cadena vacía devuelve una cadena vacía', () {
        expect(StringUtils.capitalize(''), equals(''));
      });

      test('Un solo carácter', () {
        expect(StringUtils.capitalize('a'), equals('A'));
      });
    });

    group('reverse()', () {
      test('Invertir una cadena normal', () {
        expect(StringUtils.reverse('EJEMPLO'), equals('BOONUR'));
      });

      test('Invertir una cadena palíndroma no cambia', () {
        expect(StringUtils.reverse('aba'), equals('aba'));
      });

      test('Cadena vacía', () {
        expect(StringUtils.reverse(''), equals(''));
      });
    });

    group('isPalindrome()', () {
      test('Una cadena palíndroma devuelve true', () {
        expect(StringUtils.isPalindrome('racecar'), isTrue);
        expect(StringUtils.isPalindrome('A man a plan a canal Panama'), isTrue);
      });

      test('Una cadena no palíndroma devuelve false', () {
        expect(StringUtils.isPalindrome('hello'), isFalse);
      });

      test('La cadena vacía se considera palíndroma', () {
        expect(StringUtils.isPalindrome(''), isTrue);
      });
    });

    group('countWords()', () {
      test('Oración normal', () {
        expect(StringUtils.countWords('Hello World Dart'), equals(3));
      });

      test('Espacios adicionales', () {
        expect(StringUtils.countWords('  Hello   World  '), equals(2));
      });

      test('Cadena vacía', () {
        expect(StringUtils.countWords(''), equals(0));
        expect(StringUtils.countWords('   '), equals(0));
      });
    });
  });
}
00:00 +12: All tests passed!

group() se puede anidar para crear una estructura jerárquica de pruebas.

Esto hace que el informe de pruebas sea más legible y facilita localizar rápidamente las pruebas fallidas.

Una buena estrategia de agrupación es organizar según el 'módulo/clase/método bajo prueba'. De este modo, cuando una prueba falle, podrás saber de inmediato qué funcionalidad tiene el problema.

setUp y tearDown

setUp se ejecuta antes de cada test, y tearDown se ejecuta después de cada test.

Se utilizan para preparar el entorno de prueba y limpiar recursos.

Ejemplo

import 'package:test/test.dart';

// Simular una clase que necesita inicialización y limpieza
class Database {
  bool isConnected = false;

  void connect() {
    isConnected = true;
    print(' Base de datos conectada');
  }

  void disconnect() {
    isConnected = false;
    print(' Base de datos desconectada');
  }

  String query(String sql) {
    if (!isConnected) throw Exception('Base de datos no conectada');
    return 'Resultado de la consulta: $sql';
  }
}

void main() {
  group('Prueba de Database', () {
    late Database db;  // Inicialización diferida con late

    // Se ejecuta antes de cada test
    setUp(() {
      db = Database();
      db.connect();
      print(' [setUp] Preparando el entorno de prueba');
    });

    // Se ejecuta después de cada test
    tearDown(() {
      db.disconnect();
      print(' [tearDown] Limpiando el entorno de prueba');
    });

    test('query() consulta normal', () {
      var result = db.query('SELECT * FROM users');
      expect(result, contains('EJEMPLO') ? result.contains('users') : result.contains('users'));
      // Aserción simplificada
      expect(result, isNotEmpty);
    });

    test('query() puede ejecutar múltiples consultas después de conectarse', () {
      var r1 = db.query('SELECT 1');
      var r2 = db.query('SELECT 2');
      expect(r1, isNotEmpty);
      expect(r2, isNotEmpty);
    });

    test('La consulta sin conexión lanza una excepción', () {
      db.disconnect();  // Desconexión manual
      expect(() => db.query('SELECT 1'), throwsException);
    });
  });
}
  数据库已连接
  [setUp] 准备测试环境
  数据库已断开
  [tearDown] 清理测试环境
  数据库已连接
  [setUp] 准备测试环境
  数据库已断开
  [tearDown] 清理测试环境
  数据库已连接
  [setUp] 准备测试环境
  数据库已断开
  [tearDown] 清理测试环境
00:00 +3: All tests passed!

Observa el orden de ejecución de setUp y tearDown en la salida: se ejecutan una vez antes y después de cada test.

setUp y tearDown garantizan que cada prueba comience desde un estado limpio, sin verse afectada por otras pruebas. Esta es la garantía clave de la independencia de las pruebas.


Pruebas asíncronas

El paquete test de Dart admite pruebas asíncronas de forma nativa.

Las funciones de prueba pueden devolver un Future; el framework esperará automáticamente a que el Future se complete.

Ejemplo

import 'package:test/test.dart';

// Función asíncrona: simula una solicitud de red
Future<String> fetchUserData(int userId) async {
  await Future.delayed(Duration(milliseconds: 100));

  if (userId <= 0) {
    throw Exception('ID de usuario no válido');
  }

  return 'Datos del usuario $userId';
}

Future<List<int>> fetchNumbers() async {
  await Future.delayed(Duration(milliseconds: 50));
  return [1, 2, 3, 4, 5];
}

void main() {
  group('Prueba asíncrona', () {
    test('fetchUserData() obtiene los datos correctamente', () async {
      var data = await fetchUserData(1);
      expect(data, equals('Datos del usuario 1'));
    });

    test('fetchUserData() lanza una excepción con ID no válido', () async {
      expect(
        () => fetchUserData(-1),
        throwsA(isA<Exception>()),
      );
    });

    test('fetchNumbers() devuelve la lista correcta', () async {
      var numbers = await fetchNumbers();
      expect(numbers, hasLength(5));
      expect(numbers, contains(3));
      expect(numbers.first, equals(1));
    });

    // Prueba de múltiples operaciones asíncronas
    test('Operaciones asíncronas consecutivas', () async {
      var data1 = await fetchUserData(1);
      var data2 = await fetchUserData(2);

      expect(data1, isNot(equals(data2)));
      expect(data1, contains('1'));
      expect(data2, contains('2'));
    });
  });
}
00:00 +4: All tests passed!

Comandos comunes para ejecutar pruebas

ComandoFunción
dart testEjecutar todas las pruebas
dart test test/calculator_test.dartEjecutar un archivo de prueba específico
dart test --name="add"Ejecutar solo las pruebas cuyo nombre contenga "add"
dart test --concurrency=4Ejecutar pruebas de forma concurrente (aceleración)
dart test --reporter=expandedModo de salida detallada
dart test --coverage=coverageGenerar datos de cobertura de pruebas

Buenas prácticas de pruebas

PrácticaExplicación
Cada prueba verifica solo una cosaUn test() corresponde a un comportamiento; falla más rápido la localización
El nombre de la prueba describe el comportamiento, no la implementación"add() suma dos números positivos" es mejor que "Prueba de add()"
Patrón AAAArrange (Preparar) → Act (Ejecutar) → Assert (Afirmar)
Escribir primero la prueba que fallaConfirma que la prueba puede capturar el error, luego escribe código para que pase
Pruebas de condiciones límiteValores nulos, cero, negativos, valores máximos, valores mínimos
Mantener las pruebas independientes entre síEl resultado de una prueba no debe afectar a otra prueba

Las pruebas no son una carga, sino un seguro. Cuantas más pruebas escribas, más confianza tendrás al refactorizar. Cuando una prueba falla, no necesitas adivinar dónde está el problema: la prueba te lo dirá con precisión.


Resumen del capítulo

Este capítulo presenta el flujo completo de las pruebas unitarias en Dart: instalación del paquete test, escritura de casos de prueba con test(), uso de aserciones con expect, organización en grupos con group(), gestión del entorno con setUp/tearDown y pruebas asíncronas.

Las pruebas unitarias son una asignatura obligatoria para los desarrolladores profesionales; adquirir el hábito de escribir pruebas hará que la calidad de tu código mejore continuamente.


Resumen del tutorial completo

¡Felicidades! Has completado el aprendizaje introductorio del lenguaje de programación Dart.

Repasemos la ruta de aprendizaje de estos 21 capítulos:

EtapaCapítuloContenido principal
I. Fundamentos de introducciónCapítulos 1-4Visión general de Dart, configuración del entorno, primer programa, sintaxis básica
II. Tipos principales y flujo de controlCapítulos 5-8Variables y tipos de datos, operadores, flujo de control, colecciones
III. Funciones y programación orientada a objetosCapítulos 9-13Funciones, clases y objetos, herencia y polimorfismo, interfaces y Mixins, genéricos
IV. Características avanzadasCapítulos 14-17Enumeraciones y símbolos, manejo de excepciones, gestión de paquetes y bibliotecas, typedef
V. Programación asíncrona y pruebasCapítulos 18-21Programación asíncrona, flujos Stream, concurrencia e Isolates, pruebas unitarias

Al dominar estos contenidos, ya tienes la capacidad de utilizar Dart para el desarrollo diario.

El siguiente paso es profundizar enel framework Flutter, aplicando los conocimientos de Dart al desarrollo de UI en móvil y web.

Otras extensiones