Hace poco me he topado con el Fitness Function Driven DevelopmentSe abre en una nueva pestaña. Es esta nueva práctica que parece encajar de maravilla con el desarrollo agéntico. Dice que debemos tener formas de validar que el proyecto sigue las buenas prácticas, la arquitectura y las convenciones del equipo, ya sea de forma computacional (sobre todo) o inferencial.
Cuando validamos un proyecto computacionalmente significa que hay código que ejecuta comprobaciones de forma determinista. Lo que a su vez significa que, si las ejecutamos una y otra vez y nada cambia, el resultado tampoco cambia.
Soy de esas personas que para las comprobaciones disfrutan del determinismo. Si no, ¿de verdad es una comprobación?
Las comprobaciones inferenciales, en cambio, son cómo los humanos (o la IA) perciben, por ejemplo, una base de código. Podemos apoyarnos en sensaciones y opiniones de los desarrolladores sobre lo robusta que es. Dependiendo de a quién preguntes, qué preguntes o cuándo preguntes, obtendrás respuestas distintas. Ergo, no es determinista.
Cuando oí hablar del Fitness Function Driven Development apenas pude contener la risa. Me dije: espera, ¡esto no es nada nuevo! De alguna forma, conocer las buenas prácticas y aplicarlas me ha dado ventaja en algo que llevo haciendo los últimos 10 años. Ahora no solo tenemos que pensar en enseñar buenas prácticas a los humanos: la IA también necesita entender cuáles son las buenas prácticas de nuestro equipo. Y la mejor forma de hacerlo es con comprobaciones computacionales.
#Cómo abordar el FFDD
Lo primero que quiero abordar del Fitness Function Driven Development es: el testing.
“”Los tests son esenciales en la era de la IA.
#Tests E2E
Si el proyecto no tiene una arquitectura sólida, empiezo añadiendo tests E2E. Suelo seguir el Page Object Model junto a un Flow Object. Se complementan extremadamente bien.
Primero empiezo definiendo el Page Object (PO):
import { expect, Page, test } from '@playwright/test'
class UserPo {
readonly selectors = {
user: 'Current user',
name: 'User name',
}
constructor(private readonly page: Page) {}
async open(): Promise<this> {
await this.page
.getByRole('button', { name: this.selectors.user })
.click()
return this
}
async getName(): Promise<string> {
return this.page
.getByRole('heading', { name: this.selectors.name })
.innerText()
}
}Tiene selectores y sabe cómo interactuar con partes concretas de una página o cómo recuperarlas. Después tenemos el Flow Object. Sabe cómo interactuar con varios Page Objects para representar un flujo de la aplicación.
interface BookingUserFlowParams {
userPo: UserPo
bookingPo: BookingPo
}
class BookingUserFlow {
private constructor(
private readonly userPo: UserPo,
private readonly bookingPo: BookingPo,
) {}
static create({
userPo,
bookingPo,
}: BookingUserFlowParams): BookingUserFlow {
return new BookingUserFlow(userPo, bookingPo)
}
async execute(): Promise<void> {
await this.userPo.open()
const userName = await this.userPo.getName()
await this.bookingPo.selectUser(userName)
await this.bookingPo.book()
await this.bookingPo.expectBookingCreated()
}
}Con esto tenemos un test semántico, ligero y efectivo.
test('books a vehicle for the current user', async ({ page }) => {
const flow = BookingUserFlow.create({
userPo: new UserPo(page),
bookingPo: new BookingPo(page),
})
await flow.execute()
})Las aserciones se quedan en el flow, ya que podemos necesitar acceder a datos de diferentes páginas
#Tests de integración
Suelo trabajar con tests de integración. La forma en la que los manejo es a través del Vitest Browser Mode. Para mí, los tests de integración en el frontend normalmente significan probar interacciones complejas entre componentes, sin necesidad de acceder a la página a través de una URL.
El browser mode los carga en el DOM.
Así:

Los otros casos de uso de los tests de integración son... ¡los casos de uso! Cuando algunos casos de uso son un poco complejos, los pruebo juntos.
describe('GetDestinationsQry', () => {
it('should get destinations', async () => {
const { getDestinationsQry, destinationRepository } = setup()
when(destinationRepository.findAll()).thenResolve([DestinationMother.europe()])
const result = await getDestinationsQry.handle()
expect(result).toEqual([DestinationMother.europe()])
})
})
function setup() {
// 1. Unit test
// const destinationRepository = mock<DestinationRepository>()
// 2. Integration test
// const destinationRepository = new DestinationInMemoryRepository()
const getDestinationsQry = new GetDestinationsQry(destinationRepository)
return { destinationRepository, getDestinationsQry }
}Dado que sigo la Inversión de Control y la Inversión de Dependencias, hacer el cambio es trivial.
¿Quieres leer más sobre estos patrones? Échale un vistazo a este número sobre SOLID.
#Tests unitarios
Los tests unitarios son bastante parecidos a los tests de integración que te he enseñado antes. La diferencia es que me apoyo en mocks para tener más control sobre la unidad que estoy probando.
Sigo el enfoque F.I.R.S.T y prefiero tener una función setup al final del fichero. Para mí es más importante leer primero qué hacen los tests que los mocks.
Si el setup() necesita algo de configuración, se la paso como un objeto de configuración: setup({ mockRepository: true }).
Para mockear uso @typestrong/ts-mockito. Me encanta. Simple y elegante. La única pega es que el dueño de la librería original la abandonó. Nadie sabe por qué. En fin, la librería rozaba la perfección, solo le faltaban algunos detalles que la organización typestrong ha cubierto.
Uso de 3 a 4 ficheros vitest.config distintos, uno por cada tipo de test. Para los ficheros sigo la siguiente convención:
foo.unit.test.tsbar.it.test.tsbaz.e2e.test.tsbaz.arch.test.ts
#Tests de arquitectura
La forma en la que fuerzo la arquitectura últimamente es también a través de tests. Quiero asegurarme de tener algunas de estas comprobaciones:
- Máximo 300 líneas por fichero
- Alta cohesión
- Por capas (como el propio nombre de esta newsletter)
Uso ArchUnitTS para convertir las capas en comprobaciones deterministas. Aquí tienes un ejemplo ilustrativo:
import { projectFiles } from 'archunit'
describe('Layers', () => {
it('domain should not know about any other layer', async () => {
const rule = projectFiles()
.inFolder('src/features/**/domain/**')
.shouldNot()
.dependOnFiles()
.inFolder('src/features/**/{application,infrastructure,delivery}/**')
await expect(rule).toPassAsync()
})
it('application should only know about the domain', async () => {
const rule = projectFiles()
.inFolder('src/features/**/application/**')
.shouldNot()
.dependOnFiles()
.inFolder('src/features/**/{infrastructure,delivery}/**')
await expect(rule).toPassAsync()
})
})#Linting y formateo
Para forzar reglas de lint últimamente he estado usando Biome en lugar de ESLint, ya que es mucho más rápido y además puedo reemplazar Prettier con él. Dependiendo del proyecto, configuro un conjunto de reglas personalizadas o amplío las recomendadas.
En cuanto al formateo, sí, soy de esas personas que evitan los puntos y coma. Oye, menos tokens que leer para la IA.
El formateo y el linting los ejecuto en un pre-commit. Para eso uso lefthook.
Dependiendo del proyecto, ejecuto algunos tests en el pre-commit o los muevo al pre-push. No hace falta gastar tiempo de CI para descubrir que se te ha escapado un console.log.
#Compilación
TypeScript es obligatorio. Ejecuto las comprobaciones de compilación con la configuración más estricta que puedo tener. Algo así:
{
"compilerOptions": {
// Environment (most modern)
"target": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true,
// Strictness beyond the (now default) strict family
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
// Module discipline
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"erasableSyntaxOnly": true
}
}Para simplificar, tiendo a compilar para navegadores modernos, aunque depende del proyecto.
#Comprobaciones inferenciales con IA
He estado experimentando con comprobaciones inferenciales usando Code Review Agents personalizados que revisan el código según diferentes lentes:
- Corrección
- Seguridad
- Tests
- Arquitectura
- Rendimiento
- Legibilidad
Cada lente es su propia skill. La skill /review lanza un agente desde un contexto limpio. En proyectos maduros de programación completamente agéntica, la skill review no debería hacer falta.
Si tenemos una base sólida, con skills, el harness y comprobaciones computacionales, revisar con IA el código generado por IA no tiene demasiado sentido: el código ya debería ser impecable.
Sin embargo, cuando cambias por completo el enfoque de un proyecto, los agentes tienden a dar más peso a cómo están hechas las cosas actualmente que a seguir las skills. Entonces sí que tiene sentido la skill review.
#Conclusión
Las Fitness Functions Arquitectónicas ayudan muchísimo a las IAs (¡y a los humanos también!) a alcanzar convenciones reproducibles y una arquitectura sólida de forma determinista.
Aquí tienes algunas de las herramientas que he mencionado durante este número:
- PlaywrightSe abre en una nueva pestaña
- Vitest Browser ModeSe abre en una nueva pestaña
- @typestrong/ts-mockitoSe abre en una nueva pestaña
- ArchUnitTSSe abre en una nueva pestaña
- BiomeSe abre en una nueva pestaña
- lefthookSe abre en una nueva pestaña
- TypeScriptSe abre en una nueva pestaña
P.D.: Aquí tienes, para ti, una de mis skills más preciadas: /interview