En el último proyecto que he estado trabajando, una aplicación Full Stack con NextJS, me encontré con un problema: al conectar Front y Back, no logramos obtener el ID de un elemento desde la Request del Front en el back, y eso no permitía actualizar un recurso en la aplicación, en pocas palabras un bug, ¿cuál fue el problema? Cómo se estaba obteniendo el segmento dinámico de la URL, cuando se usa la versión App Router de Next.js. Al no haber tests que verificaran que la comunicación HTTP se estuviera haciendo adecuadamente, hasta que no hicimos pruebas desde el front, no nos dimos cuenta. Los controladores de la API estaban testeados unitariamente, es decir, aislado de sus dependencias. Esto es positivo para verificar diferentes combinatorias, pero no cubre el problema del bug mencionado. Entonces, ¿qué se podía hacer? ¿Hacer un test end-to-end (e2e) desde la interfaz? En el proyecto nos separamos la parte frontal y la parte back con un compañero, yo me encargué del Back, con lo cuál tenía cierta dependencia. Con esto, intenté trabajar la parte de testing e2e desde la UI para cubrir el error. Sin embargo, el error, aunque reproducido, no daba pistas de donde venía, a la par que daba un nivel de complejidad innecesario. Así pues, decidí acotar y trabajar desde un usuario/cliente HTTP con el principal actor: el controlador.
A priori, es “sencillo” hacer un test e2e contra REST API. Automatizas la petición HTTP con las cabeceras adecuadas y el contenido, ya sea bien por URL (GET), o por body (POST o PUT) y verificas que el código de respuesta sea el esperado. Por ejemplo, si se crea un nuevo recurso vía una petición POST, un código 201 o un Ok (2XX, código exitoso). Sin embargo, está aplicación tiene endpoints protegidos, es decir, es necesario autenticarse. En concreto, este proceso de autentificación se gestiona con NextAuth.js, una solución que facilita la implementación de esta característica en aplicación, hecha en Next.js. Ahora bien, la gestión de la sesión, a la hora de hacer testing, complicaba un poco las cosas, pues requería gestionar cookies al hacer el log in (obtener las credenciales), cuestión que no se podía hacer sencillamente solo lanzando peticiones HTTP directamente. Es más, es necesario hacer en orden las peticiones para ir obteniendo la información necesaria para hacer el login:
url_base/api/auth/csrf
para obtener el token csrf (requerido para hacer peticiones POST al login)./api/auth/signin/:provider
pasando como dato el token csrf (en nuestro caso usamos credentials para gestionar esta parte en desarrollo y testing).Aquí estaba la complejidad, pues no encontré fácilmente cómo resolverla, con el fin de hacer unos tests que cubrieran o verificaran que la comunicación HTTP era correcta. Hacer mocking tenía sentido si quería probar combinatorias independientes de esto.
Entre diferentes soluciones que vi como Cypress, Axios y otros, el que funcionó bastante bien y de forma simple fue Playwright. Además, evitaba meter más dependencias de desarrollo en el proyecto. La estrategia es simple para el caso de una autentificación basada en cookies, al hacer el login, Playwright proporciona una forma de persistir esas cookies en un fichero.json. Seguidamente se puede crear un contexto que sea capaz de cargar éstas automáticamente. De esta manera, las siguientes peticiones HTTP estarían autorizadas y sería tan simple como se ve a continuación:
import {test, expect, APIRequestContext, Browser, BrowserContext} from "@playwright/test";
test.describe('An user with employee profile created', () => {
const apiURL = "/api/tu-endpoint"
test.beforeAll(async () => {
await clearDatabase()
})
test.beforeEach(async () => {
await fillDatabase()
});
test('cannot do nothing without authentication', async ({request}) => {
const resPOST = await request.post(apiURL)
const resGET = await request.post(apiURL)
const resPUT = await request.post(apiURL)
expect(resPOST.status()).toBe(401);
expect(resGET.status()).toBe(401);
expect(resPUT.status()).toBe(401);
});
test('can register valid time tracks', async ({request, browser, page}) => {
const context = await authentication(request, browser);
const res = await postTimeTrack(context, {
timeIn: '2019-09-27T04:00:00Z',
projectId: '1',
timeOut: '2019-09-27T05:00:00Z',
});
expect(res.status()).toBe(201);
});
test('can obtain all time tracks registered', async ({request, browser, page}) => {
const context = await authentication(request, browser);
await postTimeTrack(context, {
timeIn: '2019-09-27T04:00:00Z',
projectId: '1',
timeOut: '2019-09-27T05:00:00Z',
})
const {resGET, timeTracksRegistered} = await getTimeTracks(context);
expect(timeTracksRegistered.length).toBe(1);
expect(resGET.status()).toBe(200);
});
test('can update a time track registered', async ({request, browser, page}) => {
const context = await authentication(request, browser);
await postTimeTrack(context, {
timeIn: '2019-09-27T04:00:00Z',
projectId: '1',
timeOut: '2019-09-27T05:00:00Z',
})
const {resGET, timeTracksRegistered} = await getTimeTracks(context);
await putTimeTrack(context, timeTracksRegistered[0]);
expect(resGET.status()).toBe(200);
});
test('cannot register invalid time tracks', async ({request, browser, page}) => {
const context = await authentication(request, browser);
const response = await postTimeTrack(context, {
timeIn: null,
projectId: '1',
timeOut: '2019-09-27T06:00:00Z',
})
expect(response.status()).toBe(400);
});
async function authentication(request: APIRequestContext, browser: Browser) {
const responseCSRFToken = await request.get("/api/auth/csrf");
const CSRFTokenJSON = await responseCSRFToken.text();
const CSRFToken = JSON.parse(CSRFTokenJSON)["csrfToken"]
await request.post("/api/auth/callback/credentials", {
data: {
csrfToken: CSRFToken,
username: "pepito@org.es",
password: "irrelevant"
}
});
await request.storageState({path: 'tmp/state.json'});
return await browser.newContext({storageState: 'tmp/state.json'});
}
async function getTimeTracks(context: BrowserContext) {
const resGET = await context.request.get(apiURL);
const body = await resGET.json()
const timeTracksRegistered = body['timeTracks']
return {resGET, timeTracksRegistered};
}
async function postTimeTrack(context: BrowserContext, timeTrack: any) {
return await context.request.post(apiURL, {
headers: {
'content-type': 'application/json'
},
data: {
timeTrack:{
timeIn: timeTrack.timeIn,
projectId: timeTrack.projectId,
timeOut: timeTrack.timeOut,
}
}
});
}
function putTimeTrack(context: BrowserContext, timeTrackRegistered:any) {
return context.request.put(apiURL, {
data: { timeTrack: {
id: timeTrackRegistered.id,
timeIn: timeTrackRegistered.timeIn,
projectId: timeTrackRegistered.projectId,
timeOut: timeTrackRegistered.timeOut,
}}
});
}
test.afterEach(async ({page}) => {
await clearDatabase()
});
})
No logré obtener una solución tan sencilla como esta con Axios o Fetch, que en principio deberían dar mayor ligereza al lanzar peticiones HTTP, con lo cual, el trade-off aquí estuvo más en trabajar con una solución sencilla. Además, era una herramienta que ya estábamos usando y era fácil crear una configuración independiente, para que no contaminara la ya existente.
Por otro lado, es necesario hacer testing como éste cuando hay una separación de trabajo, para asegurar desde el principio que el subsistema funcionará correctamente. Esto es porque deberá responder adecuadamente peticiones HTTP, además de permitir hacer un desarollo mejor guiado por tests, desde una perspectiva Outside-In. Con lo cual, estos tests cubren la comunicación HTTP en el happy path y algún edge case importante. Luego, se pueden hacer test unitarios del controlador para trabajar las combinatorias, y así hasta cruzar el dominio y verificar las demás piezas de infraestructura, que se adaptan a nuestro sistema, pero trabajan más desde el punto de vista de persistencia de los datos de entrada, transformados por nuestro sistema:
¿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?
Pero espera 🖐 que tenemos un conflicto interno. A nosotros las newsletter nos parecen 💩👎👹 Por eso hemos creado la LEAN LISTA, la primera lista zen, disfrutona y que suena a rock y reggaeton del sector de la programación. Todos hemos recibido newsletters por encima de nuestras posibilidades 😅 por eso este es el compromiso de la Lean Lista