leanmind logo leanmind text logo

Blog

Código Sostenible

Cómo escribir código fácil de mantener mediante valores, principios y técnicas.

Cómo mantener un desarrollo con TDD rápido y eficaz para cumplir los requisitos funcionales

Por Mario S. Pinto Miranda

El desarrollo de software guiado por tests requiere un elemento fundamental para ser efectivo: un feedback rápido. Esto es crucial para la ejecución continua de los tests, especialmente durante la etapa de mejora del diseño o refactorización. Sin embargo, lograrlo en un proyecto real no es tarea fácil, ya que el número de tests puede superar el centenar, con diferentes tiempos de ejecución. En mi experiencia, he enfrentado esta dificultad, lo que complicaba la realización de cambios en el código. Con el tiempo, aprendí que no era necesario ejecutar todos los tests al trabajar en una sección específica de la aplicación, bastaba con asegurarme de que el conjunto de elementos acoplados continuaran pasando los tests. Esto representó una mejora, y durante un tiempo fue el mejor enfoque que tuve, hasta que comencé a ver los webinars de “Diseño a la gorra” de 10pines, dirigidos por Hernán Wilkinson. Estos tuvieron un gran impacto en mí, pues ampliaron mi perspectiva sobre el desarrollo guiado por tests. Aprendí a enfocar los tests para aliviar el dolor de la ejecución prolongada y, además, pude conectar estos conocimientos con otros relacionados con el diseño orientado a objetos y el testing. El resultado es una estrategia que, actualmente en el equipo que estoy, estamos aplicando, pues nos permite facilitar el cambio y, lo más importante, obtener un feedback rápido. A continuación, te explicaré esta estrategia en detalle.

Un cambio de perspectiva sobre los tests

Al trabajar con tests, es común hablar de los niveles de testing, que incluyen: Test unitario, Test de integración y Test end to end. Aunque esta es una forma extendida de categorizar los tests, hay varias perspectivas sobre el significado de estas tipologías. La más interesante probablemente sea la que proponen Steve Freeman y Nat Pryce en su libro Growing Object-Oriented Software, Guided by Tests:

Aunque este enfoque es razonable, se centra principalmente en la granularidad o el alcance del código. En el webinar Todo lo que quisiste saber sobre los tests, Hernan Wilkinson presenta una visión más interesante: la distinción entre tests para programadores y tests de infraestructura o integración. Este enfoque proporciona una manera simple y útil de entender los tests:

Resulta interesante que Robert C. Martin también aborda esta visión en su libro Clean Craftsmanship Disciplines, Standards and Ethics:

Test de programador: Test escrito por y para programadores con el propósito de especificar el comportamiento del sistema.

Esta visión forma la base de la estrategia que propongo. Ahora queda por resolver: ¿Cómo se implementa esto? Veamos los puntos clave:

Arquitectura de Puertos y Adaptadores

Esta arquitectura se basa en tres capas principales:

Para la estrategia de testing, la capa de aplicación es clave, ya que en ella se declaran los puertos o interfaces (ya sea dentro de un caso de uso o un servicio), que actúan como barreras y permiten la intercambiabilidad de la infraestructura. Al realizar pruebas sobre estos elementos, se validan los requisitos funcionales en su totalidad.

Si utilizamos implementaciones reales de la infraestructura en estas pruebas, podríamos enfrentarnos a ralentizaciones significativas. Mientras que los tests normalmente se ejecutan en milisegundos, el uso de implementaciones reales puede extender el tiempo de ejecución a segundos, ¡un aumento de tres órdenes de magnitud! Aunque pueda parecer insignificante, el problema surge cuando esos segundos se multiplican por el número de tests. Por ejemplo, con 60 tests, podríamos estar esperando un minuto tras cada pequeño cambio.

Para mitigar este problema, se suelen utilizar dobles de tests que aceleran la ejecución. Sin embargo, esto significa que no tendríamos verificación de que las implementaciones reales funcionarán. Por ello es necesario hacer tests de aceptación, que comprueban que todo funciona correctamente en producción, pero esto trae consigo un cierta duplicidad no explícita. ¿Existe una alternativa a esta duplicación de tests? Vamos a analizar el uso de dobles de tests:

Dobles de Test

Vamos a hablar de los dobles de test utilizando la clasificación formal de Meszaros y el análisis de Robert C. Martin en Clean Craftsmanship: Disciplines, Standards and Ethics. La siguiente imagen, tomada del libro, es un buen resumen:

image|370x500

Esta jerarquía es crucial para la estrategia de testing. Como se puede observar, hay dos ramas de dobles de test. La diferencia principal radica en que una rama implementa comportamiento (Fakes) y la otra consiste en objetos que se configuran para realizar una acción, que facilita los tests o su verificación. Los primeros, hasta llegar al Stub, son razonables y poco acoplantes entre los tests y el código de producción. Sin embargo, los Spy y Mocks provocan un acoplamiento con la implementación del algoritmo detrás de los casos de uso o servicios (testing de caja blanca), haciendo que los tests sean frágiles ante cambios. Por lo tanto, salvo en código legacy, no es el mejor enfoque para facilitar un feedback loop, ya que requiere re-configurar estos tests cada vez que se rompen al aplicar refactoring.

Por otro lado, están los Fake Objects, que funcionan como piezas reales pero simplificadas. Robert C. Martin no les da mucha importancia, ya que al crecer las aplicaciones, estos objetos se vuelven más complejos y difíciles de mantener, llegando al punto de requerir escribir tests para ellos. Sin embargo, si lo analizamos bien, los Fake Objects permiten mantener un testing de caja negra similar a usar implementaciones reales, pero con la ventaja de mantener ejecuciones rápidas, del orden de milisegundos. No obstante, si un Fake Object no funciona correctamente, puede suceder lo que Sandy Metz menciona en su libro Practical Object-Oriented Design (2ª Edición) cuando discute la elección entre inyectar una dependencia real (usada en producción) o un doble de test. En este caso, el test funcional podría no fallar, pero la aplicación sí.

Entonces, ¿es inevitable usar implementaciones reales y asumir el coste? Sandy Metz propone un enfoque de testing basado en roles que permite asegurar la fiabilidad de los fake objects. 25Sin coste adicional! Es decir, sin necesidad de escribir y mantener nuevos tests. !Veámoslo!

Testing de roles

Como mencionamos en el apartado de Arquitectura de Puertos y Adaptadores, la capa de aplicación define unas interfaces a las cuales la infraestructura se “conecta” para interactuar con la aplicación. Estas interfaces, en realidad, son roles o duck types como los describe Sandy Metz. Nos referimos a que varios objetos de diferente naturaleza en un contexto determinado, pueden asumir un rol para cumplir un propósito específico. Un ejemplo común en una aplicación es el Repositorio. Según Sandy Metz, podríamos crear tests compartidos, o más correctamente, testear el rol. A nivel práctico, esto consiste en parametrizar una suite de tests, donde en la parametrización se incluyen las diferentes implementaciones de la interfaz que define el rol, incluyendo también los fake objects. De esta forma, el objeto realmente se convierte en un elemento intercambiable con otros elementos reales. Sin embargo, podrías pensar que ejecutar esta suite sería aún más costoso, ya que se testean todas las implementaciones. Pero esto tiene una solución simple.

Configuración de ejecución de tests

En este punto es donde se hace explícita la tipología de tests que trata esta estrategia, pues debemos crear dos configuraciones para lanzar todos los tests: una para los tests de programadora y otra para los de infraestructura. Dependiendo del framework de testing, se puede hacer de una manera u otra. Por ejemplo con vitest, esto se puede gestionar con dos ficheros vitest.config.ts, donde en uno establezca una variable de entorno, que condicione la construcción del array de implementaciones que se usan en la parametrización del rol. Es decir, para los tests de programadora se permite que el array solo contenga el fake object, y el resto solo se incluyan en la otra configuración. De esta manera, ya tendremos la posibilidad de testear todo al completo con feedback loop rápido.

La estrategia en acción, un ejemplo para ver en código la estrategia

A continuación vamos a ver un pequeño ejemplo con typescript para ver la estrategia en acción. El problema que vamos a ver, es una porción de una aplicación empresarial: el fichaje de empleados. En la aplicación se pueden crear, leer los registros horarios y gestionar el contador de tiempo. Digamos que tenemos que desarrollar un caso de uso que sea “Encontrar el fichaje en progreso”, para que en un frontal se pueda mostrar cuando tiempo lleva trabajando el trabajador y a que hora ha empezado. En el backend escribiríamos algo así:

class FindTimeTrackInProgress {
	constructor(private readonly timeTracksRepository: TimeTracksRepository)

	execute(): TimeTrack {
		// Code
	}
}

Aquí se declara esa barrera con la infraestructura con la abstracción TimeTracksRepository. Esta podría tener la siguiente interfaz:

export interface TimeTracksRepository {  
    create(timeTracking: TimeTrack): Promise<void>  
    findAllTimeTracksOf(employeeId: string): Promise<TimeTrack[]>  
    update(timeTrack: TimeTrack): Promise<void>  
    findTimeTrackingInProgressOf(employeeId: string): Promise<TimeTrack | undefined>  
}

Entonces, para poder testear la funcionalidad requerida, podemos crear una implementación fake de esta interfaz, una implementación en Memoria. Pero por muy simple que pueda ser, queremos asegurar que es confiable, porque usaremos esta interfaz en los otros casos de uso. Entonces, creamos la siguiente suit de tests, pero no sobre la implementación esta, sino sobre el rol, TimeTracksRepository

describe('TimeTracksRepository', () => {

	it('should return undefined if there is no time tracking in progress', async () => {  
	const repository = new TimeTracksMemoryRepository([])
	    const timetrack = await repository.findTimeTrackingInProgressOf(emailUserAuthenticated);  
	  
	    expect(timetrack).toBeUndefined()  
	})  
	  
	it('should return the time tracking in progress', async () => { 
		const repository = new TimeTracksMemoryRepository([])
	    const timeIn = '2021-01-01T12:00:00Z'  
	    const employee = 'mario@gmail.com'
	    const timetrack = TimeTrack.from('mario@gmail.com',timeIn)
	    await repository.create(timetrack)  
	  
	    const timeTrackInProgress = await repository.findTimeTrackingInProgressOf(employee)!;  
	  
	  
	    expect(timeTrackInProgress).toStrictEqual(timetrack)
	})
	
})
}

Para esto, podríamos resolver los test con el siguiente código (ojo sólo muestro resultados, pero debe ser resultado de un desarollo iterativo-incremental):

export class TimeTrackMemoryRepository implements TimeTracksRepository {

private timeTracks: TimeTrack[] = [];

findTimeTrackingInProgressOf(employeeId: string): Promise<TimeTrack | undefined> {  
    const timeTrackInProgress = this.timeTracks.find(timeTrack => {  
        return timeTrack.hasEmployee(employeeId) && timeTrack.isInProgress()  
    })  
    return Promise.resolve(timeTrackInProgress);
    }
}

En este punto, podríamos usar esta implementación fake como un elemento indistinguible con seguridad en nuestros test funcionales, manteniendo un enfoque de test de caja negra. Sin embargo, cuando queramos usar una implementación real, ¿cómo lo hacemos? Pues simplemente hay que parametrizar los tests de TimeTracksRepository:

describe.each([
 {type: 'memory', repository: new TestTimeTracksMemoryRepository()},
 {type: 'mongodb', repository: new TestTimeTracksMongoDbRepository()},
])('TimeTracksRepository type $type', ({repository}) => {

	beforeEach(() => {
	// Code to set initial repository state correctly
	})
	
	afterEach(() => {
	// Code restablish de repository
	})
	
	afterAll(() => {
	// Code close "connection" with extenal services or something else
	})

	it('should return undefined if there is no time tracking in progress', () => { 
       //code
    })
	  
	it('should return the time tracking in progress', () => {
       //code
     })
	
})
}

De esta manera, todos los tests se ejecutan para cada implementación del rol. Es decir, ya tenemos escritos los tests para cubrir la nueva implementación que vamos a usar en producción. Sólo tenemos que ir resolviendo éstos con código de forma incremental. Por esto, nuestra implementación en memory se vuelve indistinguible.

Un pequeño inciso, no hay un typo con la palabra test delante de los nombres de las implentaciones. Es una práctica recomendada no testear directamente los objetos, sino usar una Subclase específica de test para facilitar el testing. En nuestro caso, permite introducir ciertas utilidades como limpiar (clean) el repositorio, algo que no forma parte de la interfaz del rol, pero es necesario para hacer test deterministas. También nos sirve para encapsular la conexión e instanciación de la implementación de MongoDB, simplificando así su uso en la suit de tests.

Dicho lo cual, queda resolver la ejecución de test, porque ahora mismo todos los tests se ejecutarían y los de MongoDB realentizan al menos una orden de magnitud, (ahora mismo es nada, pero más cosas en otras verticales provocarán una ralentización). Para ello, asumimos que usamos Vitest como framework de testing. Este nos permite crear configuración de ejecución vía ficheros de configuración. El mínimo esfuerzo es condicionar la construcción de array de implementación, bajo una variable de entorno:


const timeTracksRepositories = (isIntegrationTesting) => {
	const fakeRepository =  { type: 'memory', repository: new TestTimeTracksMemoryRepository() }
	
	if(isIntegrationTesting){
		return [
			  fakeRepository
			 { type: 'mongodb', repository: new TestTimeTracksMongoDbRepository() }
		]
	}

	return [ fakeRepository ]
	
}

describe.each(timeTracksRepositories(process.env.EXECUTE_INTEGRATION_TESTS))
('TimeTracksRepository type $type', ({repository}) => {
    //tests
})

Por otro lado, creamos la configuración para setear esta variable de entorno:

//vitest.with-integration.config.ts

// https://vitejs.dev/config/
import {configDefaults, defineConfig} from 'vitest/config'
import {resolve} from 'path'

export default defineConfig({
  test: {
    globals: true,
    setupFiles: './tests.setup.ts',
    env: {
      "EXECUTE_INTEGRATION_TESTS": "true" //HERE!
    }
  }
})

Con todo esto, ya podemos finalmente ejecutar tanto por IDE como por linea de comandas directamente con los script de npm, los dos conjuntos de tests por separados. En el segundo caso sería algo así:

//Package.json
scripts: {
    "test:for_programming": "Vitest run",
    "test:with_integration": "Vitest run --config=vitest.with-integration.config.ts",
}

image|690x379

Conclusiones

Con todo lo anterior, logramos mantener en el conjunto de tests que usamos para cumplir los requisitos funcionales, un feedback loop rápido. Al mismo tiempo, mantenemos la seguridad de que la aplicación funcionará, porque la figura del Fake Object se vuelve un elemento intercambiable/indistinguible con implementaciones de producción. Esto se debe a que la capa de aplicación no entiende de Fake Object, entiende de objetos que cumplen un rol (declarado en la interfaz), que sean capaces de responder a las peticiones (cumplir el protocolo) y tenemos la seguridad de que este Fake Object está alineado con las otras implementaciones, porque tenemos constatado con tests que funciona cómo el rol con el que es usado. El resultado final, es una estrategia que permite mantener a las personas desarrolladoras en un ritmo de trabajo no lastrado por la ejecución lenta de los tests, con lo cual, con más incentivo para poder hacer mejoras continuas en el diseño aplicando refactoring. Por otro lado, hay otros tipos de testing más allá de asegurar los requisitos funcionales, como los de carga que no son objeto de esta estrategia, pero no son el centro o al menos la preocupación diaria en el desarrollo.

Agradecimientos

Gracias a Leanmind por dar los recursos y tiempo para poder tener una formación continúa, al equipo con el que he podido ver esto en acción: Ana, Elena, Lita y María. Y gracias a Adrián, por poner la semilla en un proyecto interno en el que colaboramos, donde él implementó parte de esta estrategia.

Referencias

Publicado el 02/09/2024 por

¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.

Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?

Impulsamos el crecimiento profesional de tu equipo de developers