Table of Contents
El mes pasado hablamos sobre Ejemplo de desarrollo aplicando TDD ( parte 1 ) y TDD: primer ciclo ( parte 2), en este nuevo artículo sobre TDD e obsesión con las primitivas haremos foco en quitar duplicación y reforzando los constructores de nuestras entidades, algo clave para tener un sistema robusto.
TDD: Obsesión con las primitivas
Aquí entra un concepto interesante que viene fuerte por el mundo de DDD que es el uso de ValueObjects y quitarnos la obsesión con las primitivas de la cabeza, una String no es un nombre, un nombre es un subconjunto de String (Tipos Refinados). En nuestro lenguaje ubicuo creado con cliente no existe string, existe Nombre o Apellido.
Dentro del conjunto de String existe «», «4», «<script src=»malicious.js» />» y todo lo que te puedas imaginar, un Name es igual? no podemos obviar esta lógica y con el Apellido pasa lo mismo, y no queremos duplicar código…
Vamos a empezar con el ValueObject de Name:
export class Name {
constructor(private value: string) {
if (value === "") {
throw new Error(`Name should not be empty`)
}
}
}
describe("given Name value object", () => {
const nameGenerator = (value: string) => new Name(value);
const emptyValueName = () => nameGenerator("");
const validName = nameGenerator("Oscar");
describe("with valid value", () => {
test("should not throw", (done: any) => {
expect(validName.toDTO()).toEqual("Oscar");
done();
});
});
describe("when empty value", () => {
test("should throw", (done: any) => {
expect(emptyValueName).toThrow();
done();
});
});
});
Tenemos un ValueObject que viene de negocio, tenemos su validación con sus tests en verde como hemos aprendido pero nuestro usuario no conoce Name por ahora, tenemos que hacer que lo conozca:
export class User {
constructor(private id: string, private name: Name, private lastname: string) {
if (lastname === "") {
throw new Error("empty lastname");
}
}
}
Perfecto, ahora el test nos está petando porque le hemos pasado un string y ahora es un Name, realmente tenemos que modificarlo? no hemos hecho algo mal por el camino? bien, la respuesta es no, es más, no sólo vamos a modificar nuestra función que crea el usuario (recordad que en el refactor previo la sacamos a una función y solo tendremos que cambiarla en un sitio) sino que encima, ELIMINAREMOS TEST! no hace falta testear que lanza una excepción cuando el name está vacío porque directamente le entra un Name, el Name ya hace esa validación, el resto se hace en compilación! El nuevo código de test quedaría así:
const createUser = (name: string, lastname: string) => new User("someId", new Name(name), lastname);
Oh! Que clase de unit es esta en la que User recibe el Name correcto? bueno, si has llegado hasta aquí sin leer el enlace de UnitTest de Fowler, es el momento de hacerlo, cuando termines lo entenderás todo (y encima te gustará, te lo prometo).
Hemos refactorizado Name pero nos falta Lastname, vamos a ello ahora que ya lo tenemos todo en verde. Este caso es bastante fácil porque es el mismo que Name! Copy & Paste time!
// test/LastNameSpec.ts
export class LastName {
constructor(private value: string) {
if (value === "") {
throw new Error(`LastName should not be empty`)
}
}
toDTO() {
return this.value;
}
}
describe("given lastname", () => {
const lastNameGenerator = (value: string) => new LastName(value);
const emptyValueLastName = () => lastNameGenerator("");
const validLastName = () => lastNameGenerator("Oscar");
describe("with valid value", () => {
test("should not throw", (done: any) => {
expect(validLastName).not.toThrow();
done();
});
});
describe("when empty value", () => {
test("should throw", (done: any) => {
expect(emptyValueLastName).toThrow();
done();
});
});
});
// src/User.ts
export class User {
constructor(private id: string, private name: Name, private lastname: LastName) {}
}
describe("given user", () => {
const createUser = (name: string, lastname: string) => new User("someId", new Name(name), new LastName(lastname));
const createValidUser = () => createUser("Oscar", "Galindo");
const createUserWithEmptyName = () => createUser("", "Galindo");
const createUserWithEmptyLastName = () => createUser("Oscar", "");
describe("with valid data", () => {
test("is created", (done: any) => {
expect(validUser.toString()).toEqual("User(someId,Oscar,Galindo)");
done();
});
});
describe("with empty name", () => {
test("should throw", (done: any) => {
expect(createUserWithEmptyName).toThrow();
done();
});
});
describe("with empty lastname", () => {
test("should throw", (done: any) => {
expect(createUserWithEmptyLastName).toThrow();
done();
});
});
});
Diría que la fase que viene ahora (REFACTOR) es muy importante pero es que las 3 lo son, es importante en todo momento saber en qué fase está uno y hacer foco. Para empezar vemos que Name y LastName tienen exactamente la misma lógica de validación, podemos sacarla a una clase más abstracta (llamémosla NonEmptyString) y que Name y LastName extiendan de ella:
export class NonEmptyString {
constructor(protected value: string) {
if (value === "") {
throw new Error(`${this.constructor.name} should not be empty`)
}
}
toDTO() {
return this.value;
}
}
export class Name extends NonEmptyString {}
export class LastName extends NonEmptyString {}
describe("given nonEmptyString value object", () => {
describe("when create with valid data", () => {
test("should not throw", (done: any) => {
const validEmptyString = () => new NonEmptyString("some random string");
expect(validEmptyString).not.toThrow();
done();
});
});
describe("with empty value", () => {
test("should throw", (done: any) => {
const invalidEmptyString = () => new NonEmptyString("");
expect(invalidEmptyString).toThrow();
done();
});
});
});
Muy bien, a nivel de código de producción ya tenemos un refactor interesante y encima todos los test en verde, lo que nos faltaría por refactorizar son los tests, tenemos un par de tests que están duplicados, gracias a este refactor podemos conseguir eliminar dos tests que son las de creación de usuario con nombre y apellido inválido, porque? porque nuestra clase User ya no entiende de strings, entiende de Name y LastName, y qué pasa con esas dos entidades? que no existen si están vacías, es imposible que alguien construya esas entidades con un valor vacío, ya lo tenemos controlado, el que un usuario se construya bien ya no depende de nuestro test sino del compilador, guay no? menos test, menos mantenimiento y nuestro código ha ganado legibilidad.
Ahora, por el camino hemos estado viendo cómo íbamos arrastrando una ID como string en nuestra Entity, ahora que entendemos el concepto de qué Name no es una string sino un Name, podemos pensar que a la propiedad ID le pasa lo mismo, tenemos que validar que la ID tenga el formato que nosotros requiramos, etc.
Sabemos que una entidad se identifica por su id, entonces, si comparamos una entidad consigo misma, deberían ser iguales, no? vamos a picar el test:
describe("when compare same entity", () => {
test("should be equal", (done: any) => {
const user = User.create("Oscar", "Galindo");
expect(user.equals(user)).toBeTrue();
done();
});
});
Ante este punto hemos creado dos métodos estáticos en la clase User, uno para crear y otro para instanciar (registrar un nuevo usuario o instanciar uno ya registrado previamente), con la única diferencia que uno recibe ID y el otro no.
export class User { static create(name: string, lastname: string): User { return new User(v4(), new Name(name), new LastName(lastname)); } static new(id: string, name: string, lastname: string): User { return new User(id, new Name(name), new LastName(lastname)); } }
Podríamos pensar en usar Obvious Implementation ya que la implementación es familiar y simple pero se que tenemos que hacer dos tests, una cuando dos usuarios sean iguales y otro para cuando sean diferentes por lo que iremos paso por paso, que no cuesta nada:
export class User {
...
equals(other: User): boolean {
return true;
}
}
Ya tenemos el test en verde, creamos el siguiente test que cubrirá todos los casos del método, lo ponemos en verde y listo:
// userSpec.ts
describe("when compare two differents users", () => {
test("should return false", (done: any) => {
const user = User.create("Oscar", "Galindo");
const otherUser = User.create("Pedro", "Garcia");
expect(user.equals(otherUser)).toBeFalse();
done();
});
});
// user.ts
export class User {
...
equals(other: User): boolean {
return this.id === other.id;
}
}
Entramos en la fase de Refactor, en esta fase no solo quitamos duplicación, también diseñamos nuestra aplicación dentro de este espacio que nos aporta TDD.
Un User sabe que para compararse con otro User bastaría con comparar los identificadores (id) pero, es responsabilidad del User la implementación tan concreta de ese equals? La mejor solución sería que el identificador sea el que se compare con otro identificador, o sea, tenemos que convertir id en un Value Object.
Vamos a crear UserId de igual manera que hemos creado Name, lo que vamos hacer es crear una clase con dos métodos estáticos para generar/instanciar UserId y implementar el equals dentro de nuestra nueva clase, necesitamos crear un test para el equals:
export class UserId extends NonEmptyString {
static new(id: string): UserId {
return new UserId(id)
}
static generate(): UserId {
return new UserId(v4());
}
equals(otherUserId: UserId): boolean {
return this.value === otherUserId.value;
}
}
describe("given UserId", () => {
describe("when compare with same userId", () => {
test("should return true", (done: any) => {
const userId = UserId.new("someId");
expect(userId.equals(userId)).toBeTrue();
done();
});
});
describe("when compare with other userId", () => {
test("should return false", (done: any) => {
const userId = UserId.new("someId");
const otherUserId = UserId.new("otherId");
expect(userId.equals(otherUserId)).toBeFalse();
done();
});
});
});
Y en nuestra clase podremos cambiar la implementación del equals a algo así:
export class User {
...
equals(other: User): boolean {
return this.id.equals(other.id);
}
}
Todos los tests siguen en verde con este refactor, hemos conseguido un mejor diseño y más desacoplado en un corto plazo.
Llegados a este punto, lo que buscamos es poder crear una Note así que vamos a pensar, quien crea la nota? como? qué datos necesita? Es importante entender que las entidades no suelen crearse solas, hay que saber desde donde se deben crear. Una nota no se crea sola, la crea un usuario, por lo cual es el usuario el que necesariamente tendrá un método createNote que generará una Note, vamos a crear el test de nuestro caso de uso:
describe("when create a note", () => {
test("should return a note", (done: any) => {
const authorId = UserId.new("someAuthorId");
const user = User.new(authorId, "Oscar", "Galindo");
const note = user.createNote("Some text");
expect(note.toString()).toContain(`someAuthorId,Some text`);
done();
});
});
Ahora hemos roto nuestros tests por todos los lados posibles, Note no existe, createNote no es un método de la clase User y por otro lado, el test que hacemos es que el toString() contenga tanto el nuestro authorId como el texto de la nota, no podemos compararla con una nota concreta porque la NoteID no es determinista, no tenemos manera de saber que id se va a generar en nuestra nueva nota. Hay maneras de testear estas casuísticas, aquí expongo unas cuantas:
- Accessors a las propiedades. Una manera es testeando las propiedades de la nota que ha creado, pero considero que haciendo getters a las propiedades, rompemos la encapsulación o damos pie a hacerlo (la encapsulación se rompe cuando se accede a propiedades para que desde fuera apliquemos cierto comportamiento, no cuando hacemos pública una propiedad).
- Que toda entidad sepa cómo convertirse en su DTO. Convertir la entidad a su DTO y testear sus propiedades, en este momento no tenemos DTOs en nuestra aplicación y meterlos para testear es algo engorroso.
- Controlando la manera en la que se generan las id’s, con la implementación actual nos hemos acoplado totalmente a la librería uuid, si hiciéramos un wrapper de la librería, podríamos hacer un stub del wrapper y saber en todo momento que ID se genera y así tendríamos el determinismo que buscamos cuando testeamos y poder comprobar que la Note que crea el usuario es exactamente la misma que una que podemos crear nosotros en el test.
Esta última estrategia la leí en The Art Of Unit Testing y la aplica para DateTime, básicamente lo que hacemos es quitar de la ecuación el indeterminismo a la hora de generar una nueva Id diciendole nosotros que tiene que devolver, creamos una propiedad estática generator donde tiene asignado por defecto la librería que usamos para generar las id’s y la sustituimos por una función nuestra que devuelve una string que conocemos.
Una vez explicado el test, vamos a empezar a implementar nuestro caso de uso, vamos a crear el método dentro de User que devuelva una Note nueva.
Una Note es una entidad y toda entidad se identifica por una ID, exactamente igual que User. En este caso, Note solo tendrá una propiedad (quitando su identificador) que será el texto de la nota (text):
export class NoteId {
constructor(private value: string) {}
static generate() {
return new NoteId(v4());
}
}
export class Note {
constructor(private noteId: NoteId, private authorId: UserId, private text: string) {}
static create(authorId: UserId, textNote: string): Note {
return new Note(NoteId.generate(), authorId, textNote);
}
static new(id: string, authorId: UserId, text: string): Note {
return new Note(new NoteId(id), authorId, text);
}
}
export class User {
...
createNote(textNote: string): Note {
return Note.create(this.id, textNote);
}
}
Ya tenemos un User que sabe crear Note, todos los test en verde… toca refactor. Dos reglas que podemos seguir (para mí principales) en esta fase del TDD es buscar código repetido en la misma clase y código repetido en diferentes clases (posible abstracción). (Checklist TDD)
Dentro de la clase Note no tenemos ningún tipo de duplicación pero si existe duplicación entre las clases NoteId y UserId, son ValueObjects que identifican una entidad, podemos abstraer una clase EntityId y extender NoteId y UserId de ella.
export abstract class EntityId extends NonEmptyString {
equals(entityId: EntityId): boolean {
return this.value === entityId.value;
}
}
export class UserId extends EntityId {
static new(id: string): UserId {
return new UserId(id)
}
static generate(): UserId {
return new UserId(v4());
}
}
export class NoteId extends EntityId {
static new(id: string): NoteId {
return new NoteId(id)
}
static generate(): NoteId {
return new NoteId(v4());
}
}
Conclusión: Obsesión con las primitivas y TDD
En este capítulo hemos visto cómo reforzar los constructores de nuestras entidades aprovechándose de los Value Objects, hemos eliminado test ya que la compilación hacía el resto y hemos quitado duplicación, este es el proceso que se debe seguir en un verdadero desarrollo por TDD, refactor continuo. El refactor de nuestra aplicación no es opcional, junto con un diseño evolutivo conseguimos aplicaciones robustas y que funcionan.
En el siguiente capítulo entraremos en el mundo de la persistencia, como haremos TDD con temas de infraestructura, desacoplarse lo máximo posible de la base de datos que usemos, etc.
Y no te olvides de suscribirte a nuestro boletín mensual para recibir más información sobre TDD e Obsesión con las primitivas.
Si te gustó este artículo sobre TDD e obsesión con las primitivas, te puede gustar:
Ejemplo de desarrollo aplicando TDD – parte 1
Arquitectura de microservicios vs arquitectura monolítica
Scala generics I: clases genéricas y type bounds
Scala generics II: covarianza y contravarianza
Principio de responsabilidad única
Sobre Dioses y procrastinación
Arquitectura de microservicios
Simular respuestas del servidor con Nodejs
Mapa de los “main players”: ecosistema startup y tech en Barcelona
Ecosistema de salud digital en Barcelona
Author
-
Software developer with over 16 years experience working as Fullstack Developer & Backend Developer.
Ver todas las entradas