June 24, 2026
TDD no es una red de seguridad (y tardé en entenderlo)
Llevo un buen tiempo construyendo un producto solo. Es un sistema de gestión para restaurantes — punto de venta, inventario, ventas, todo…
By Miguel Ochoa
6 min read
Llevo un buen tiempo construyendo un producto solo. Es un sistema de gestión para restaurantes — punto de venta, inventario, ventas, todo eso — y como cualquiera que haya sacado algo al mundo sin un equipo detrás, aprendí a elegir mis batallas. No todo se hace "bien". Se hace lo que se puede, con el tiempo que hay, y se reza por que la deuda no cobre intereses demasiado pronto.
Dentro de esas batallas que durante mucho tiempo decidí no pelear, estaba el testing. Y sospecho que no soy el único. Para la mayoría de nosotros, escribir tests es esa cosa que sabemos que deberíamos hacer, como ir al gimnasio o leer la documentación completa antes de usar una librería. Los tutoriales lo presentan como una virtud, casi como una obligación moral. Y a mí, honestamente, me sonaba a impuesto: tiempo que le robaba a construir features de verdad, a cambio de una tranquilidad abstracta que cuando estás corriendo para sacar una versión no se siente tan urgente.
Pensaba que los tests eran una red de seguridad. Algo que pagas por si acaso. Tardé en entender que estaba mirando el problema por el lado equivocado, y que la lección no me iba a llegar de un libro ni de un curso, sino de un combo de almuerzo.
Porque hubo una noche en que un cliente me escribió por WhatsApp con una captura: en su POS, un combo de almuerzo que costaba $12.500 aparecía en el resumen de ventas como $12,50.
No era un cobro mal hecho. La venta había quedado bien en Firestore. El problema estaba en una sola función, una que yo había escrito en treinta segundos sin pensarla dos veces:
dart
double parsePrice(String raw) {
return double.parse(raw.replaceAll(',', ''));
}double parsePrice(String raw) {
return double.parse(raw.replaceAll(',', ''));
}En Estados Unidos esto funciona. 1,250.00 → quitas la coma → 1250.00. Limpio.
Pero en Colombia escribimos el precio al revés: el punto separa los miles y la coma separa los decimales. $12.500 son doce mil quinientos pesos. Mi función recibía "12.500", no encontraba comas que quitar, y se lo entregaba feliz a double.parse, que interpretaba el punto como decimal y devolvía 12.5.
El combo de almuerzo costaba doce pesos con medio.
Arreglarlo fue trivial. Lo que me dejó pensando no fue el bug. Fue darme cuenta de por qué ese bug pudo existir, y de que la práctica que llevaba meses subestimando lo habría hecho imposible desde el primer minuto.
La versión fácil de esta historia
La moraleja obvia sería: "si hubiera tenido un test, lo habría atrapado". Y es cierto. Tres líneas:
dart
test('parsea formato de moneda colombiano', () {
expect(parsePrice('12.500'), 12500);
expect(parsePrice('1.250,50'), 1250.50);
});test('parsea formato de moneda colombiano', () {
expect(parsePrice('12.500'), 12500);
expect(parsePrice('1.250,50'), 1250.50);
});Ese test falla con mi implementación original. Fin de la historia. TDD como red de seguridad, como detector de errores, como la cosa esa que haces para dormir tranquilo.
Esa es la versión que aparece en el 90% de los artículos sobre testing. Y es la razón por la que TDD le parece aburrido a tanta gente: lo venden como un seguro contra accidentes. Algo que pagas por si acaso.
Pero esa no es la parte interesante. La parte interesante es que si hubiera escrito ese test primero, la función fea jamás habría nacido.
TDD no verifica tu diseño. Lo provoca.
Vuelve a mirar mi función original. ¿Por qué era tan mala? No porque tuviera un error de lógica. Era mala porque estaba escrita por alguien que pensaba en el código, no en el comportamiento.
Yo me senté a escribir "una función que limpia un string y lo convierte a número". Esa es una descripción de implementación. El replaceAll(',', '') salió natural de esa forma de pensar, porque en mi cabeza el problema era quitar caracteres molestos.
Ahora imagina que arranco al revés. Antes de escribir una línea de implementación, me obligo a escribir esto:
dart
group('parsePrice', () {
test('interpreta el punto como separador de miles', () {
expect(parsePrice('12.500'), 12500);
});
test('interpreta la coma como separador decimal', () {
expect(parsePrice('1.250,50'), 1250.50);
});
test('maneja valores sin separadores', () {
expect(parsePrice('500'), 500);
});
});group('parsePrice', () {
test('interpreta el punto como separador de miles', () {
expect(parsePrice('12.500'), 12500);
});
test('interpreta la coma como separador decimal', () {
expect(parsePrice('1.250,50'), 1250.50);
});
test('maneja valores sin separadores', () {
expect(parsePrice('500'), 500);
});
});Fíjate en lo que acaba de pasar. Antes de tocar la lógica, me vi forzado a responder una pregunta que originalmente nunca me hice: ¿qué formato de número estoy parseando?
El test no me protegió de un bug. Me protegió de no haber pensado el problema. La coma colombiana dejó de ser un "caracter molesto que hay que quitar" y se convirtió en lo que siempre fue: información semántica. El test, al exigirme un ejemplo concreto antes de la solución, me arrastró hasta la pregunta correcta.
Esa es la tesis de todo este artículo: el valor de TDD no está en los tests que te quedan. Está en las decisiones de diseño que te obliga a tomar mientras los escribes.
El patrón se repite en todas partes
Una vez que ves TDD como presión de diseño y no como verificación, empiezas a notarlo en todo tu código.
Cuando refactoricé las clases de acceso a Firebase en Menú Go — ProductsApi, IngredientsApi, InventoryApi — caí en varios patrones que en su momento atribuí a "experiencia" o a "buen gusto". Hoy creo que son simplemente lo que pasa cuando dejas que la testabilidad guíe el diseño, lo hagas conscientemente o no.
Patrón 1: separar validación de ejecución
La versión ingenua de crear un producto mezcla todo en un método:
dart
Future<void> createProduct(Product product) async {
if (product.name.isEmpty) throw ValidationException('Nombre vacío');
if (product.price < 0) throw ValidationException('Precio negativo');
await _collection.add(product.toJson());
}Future<void> createProduct(Product product) async {
if (product.name.isEmpty) throw ValidationException('Nombre vacío');
if (product.price < 0) throw ValidationException('Precio negativo');
await _collection.add(product.toJson());
}¿Cómo testeas la validación aquí? No puedes, sin tocar Firestore. Para verificar que un precio negativo lanza excepción, necesitas un mock de la colección, configurar el fake, y aún así estás mezclando dos preguntas distintas en un solo test: ¿valida bien? y ¿escribe bien?
El dolor de escribir ese test es el código diciéndote algo. Si separas:
dart
@visibleForTesting
void validateProduct(Product product) {
if (product.name.isEmpty) throw ValidationException('Nombre vacío');
if (product.price < 0) throw ValidationException('Precio negativo');
}
Future<void> createProduct(Product product) async {
validateProduct(product);
await _collection.add(product.toJson());
}@visibleForTesting
void validateProduct(Product product) {
if (product.name.isEmpty) throw ValidationException('Nombre vacío');
if (product.price < 0) throw ValidationException('Precio negativo');
}
Future<void> createProduct(Product product) async {
validateProduct(product);
await _collection.add(product.toJson());
}Ahora la validación se testea en aislamiento, sin mocks, sin Firestore, en microsegundos. Yo llegué a esta separación porque "se sentía más limpio". Pero la verdad es más precisa: llegué ahí porque la otra versión era dolorosa de testear, y ese dolor es una señal de diseño. TDD solo hace que la señal llegue antes, no después de seis meses de deuda.
Patrón 2: los collection reference getters
Otro patrón que se volvió automático: nunca acceder a una colección de Firestore inline, siempre detrás de un getter.
dart
CollectionReference<Map<String, dynamic>> get _collection =>
_firestore.collection('businesses/$businessId/products');CollectionReference<Map<String, dynamic>> get _collection =>
_firestore.collection('businesses/$businessId/products');Parece cosmético. No lo es. Ese único punto de acceso es exactamente la costura que necesitas para inyectar un FakeFirebaseFirestore en los tests. Si hubiera regado _firestore.collection(...) por quince métodos, cada test tendría que saber demasiado sobre la estructura interna de la clase. El getter no apareció por elegancia: apareció porque un diseño testeable necesita un único lugar donde mentir sobre de dónde vienen los datos.
Patrón 3: Future.wait y la honestidad sobre las dependencias
Cuando un método necesitaba leer producto e inventario a la vez, la versión secuencial era la obvia:
dart
final product = await _productsApi.getById(id);
final stock = await _inventoryApi.getStock(id);final product = await _productsApi.getById(id);
final stock = await _inventoryApi.getStock(id);Al escribir el test, la pregunta incómoda salió sola: ¿estas dos lecturas dependen una de la otra? No. Entonces, ¿por qué las estoy encadenando? El test me hizo declarar explícitamente que eran independientes, y eso se tradujo en código:
dart
final results = await Future.wait([
_productsApi.getById(id),
_inventoryApi.getStock(id),
]);final results = await Future.wait([
_productsApi.getById(id),
_inventoryApi.getStock(id),
]);Más rápido, sí. Pero lo importante es el mecanismo: el test me obligó a entender la relación real entre dos operaciones, algo que en la versión secuencial nunca me cuestioné.
El segundo bug, ya con la lección puesta
Meses después choqué con otro clásico de Firestore. Guardaba fechas así:
dart
Map<String, dynamic> toJson() => {
'createdAt': createdAt.toIso8601String(),
};Map<String, dynamic> toJson() => {
'createdAt': createdAt.toIso8601String(),
};Y las leía así:
dart
DateTime.parse(json['createdAt']);DateTime.parse(json['createdAt']);Funcionaba en mis pruebas manuales. Reventaba en producción de forma intermitente. ¿Por qué? Porque Firestore no devuelve el string que guardaste: si escribes un DateTime, te lo devuelve como un objeto Timestamp. Y DateTime.parse recibiendo un Timestamp explota.
Esta vez la diferencia es que ya sabía qué buscar. No me pregunté "¿cómo arreglo esto?". Me pregunté "¿qué test me habría forzado a enfrentar la diferencia entre lo que escribo y lo que Firestore me devuelve?":
dart
test('deserializa fechas que vienen como Timestamp de Firestore', () {
final json = {'createdAt': Timestamp.fromDate(DateTime(2025, 1, 15))};
final model = MyModel.fromJson(json);
expect(model.createdAt, DateTime(2025, 1, 15));
});test('deserializa fechas que vienen como Timestamp de Firestore', () {
final json = {'createdAt': Timestamp.fromDate(DateTime(2025, 1, 15))};
final model = MyModel.fromJson(json);
expect(model.createdAt, DateTime(2025, 1, 15));
});Escribir ese test antes me habría obligado a confrontar el tipo real que devuelve Firestore, no el que yo asumía. El bug no era de serialización. Era de una suposición no examinada. Y TDD, en el fondo, es una máquina de examinar suposiciones antes de que se vuelvan caras.
Entonces, ¿hago TDD en todo?
No. Y aquí es donde me separo del dogma.
No escribo tests primero para construir un Container con padding. No los escribo para la UI decorativa que va a cambiar tres veces esta semana. El costo no se justifica y el "diseño" que me forzaría a pensar es trivial.
Donde TDD gana de verdad es en el código donde una suposición equivocada cuesta dinero o confianza: parseo de moneda, serialización de modelos, lógica de un Bloc de checkout, las reglas de un periodo de prueba, cualquier cosa que toque un pago de Wompi. Ahí, el verdadero entregable no es el test. Es que escribir el test primero me obliga a entender el problema antes de comprometerme con una solución.
Mira el camino completo. La función de moneda nació mala porque pensé en código. El patrón de validación nació bien porque el dolor del test me empujó. La diferencia, en ambos casos, fue el orden en que me hice las preguntas.
TDD no es un seguro contra bugs. Es una técnica para pensar el problema antes de enamorarte de la primera solución que se te ocurra. Los tests que te quedan al final son, casi, un efecto secundario.
El combo de almuerzo de doce pesos con medio me costó una noche. Lo que me dejó valió mucho más.
Si construyes con Flutter, Bloc y Firebase, ¿en qué parte de tu código te ha "hablado" el dolor de escribir un test? Me interesa leer tus casos en los comentarios.