Ir al contenido

Las capas de prueba

Las pruebas están en tres capas, y conviene distinguirlas porque solo la primera es gratis. Dos de ellas levantan nueve contenedores, y quien decide si espera merece saber qué compra con esa espera.

CapaOrdenDockerTiempoQué demuestra
L1, el contratomake testnounos 5 slos bytes exactos que cada destino pone en el cable
L2, los almacenesmake test-e2e-dockersí53 s en calienteque un almacén real acepta esos bytes y conserva la fecha del hecho
L3, los dashboardsel mismo objetivosíincluido arribaque las consultas propias de los cinco dashboards responden contra lo que escribieron los destinos

L1 corre en cada push. L2 y L3 son una sola suite tras la etiqueta de compilación dockere2e, así que go test ./... no levanta ningún contenedor; en CI corren semanalmente, a demanda y como puerta de publicación.

test/e2e compila el binario real, lo ejecuta contra un GitHub falso cuyas fixtures están en test/e2e/testdata y cuya tabla de rutas es test/e2e/fakegh, y apunta cada destino a un servidor de captura httptest. Después comprueba los bytes: el line protocol, el sobre _bulk, las sentencias SQL, la ruta de Graphite, la carga OTLP.

El falso además cobra sus respuestas como lo hace api.github.com: un ETag por fixture REST y ninguno en las respuestas GraphQL, un 304 sin coste para la petición que lo presenta, uno por cada otra respuesta de la API, nada por el almacenamiento de objetos al que redirige un log de job, y el bloque de presupuesto en cada respuesta GraphQL. Eso es lo que permite que TestTheSecondSweepIsPricedByTheCache ejecute dos pasadas en un mismo proceso y sostenga que cada URL respondida 200 la primera vez volvió 304 la segunda, que la segunda pasada cobró menos de la mitad de las peticiones core de la primera, y que own_cost en la fila gh_rate_limit es el número de consultas que hizo el proceso y no cero.

También busca como GitHub. Cada una de las cinco búsquedas salientes pide un tipo en un estado, is:pr is:merged o is:issue is:open, y el falso le sirve solo los elementos de su fixture que seleccionan esos calificadores, descontando los demás de issueCount. Un tipo o un estado que no conoce, un is:, type: o state: para el que no tiene regla, hace fallar la prueba; el autor, los propietarios excluidos y el orden ya son los de la propia fixture, y los pasa por alto. Hasta 2.6.2 respondía a todas las búsquedas con todos los elementos, así que una pasada escribía la única pull request fusionada cinco veces, una de ellas como issue abierta, y los dashboards dibujaban esas filas.

Una fixture nunca escribe una fecha reciente. La deletrea como un desplazamiento que el falso resuelve al servir el fichero, "@DAYS_AGO_12@T00:00:00Z", junto a los @NOW@, @TODAY@ y @SOON@ que ya resolvía, y @DAYS_AHEAD_n@ para lo que tenga que seguir estando en el futuro. Un panel pide el último día, semana o mes, así que una fecha escrita a mano solo es correcta hasta que sale de esa ventana, y nada avisa del día en que lo hace: la release de 2.4.0 falló con cuatro paneles de cada almacén sin responder nada, doce horas después de que la misma suite pasara, porque entre medias el día en que se fusionó un pull request cruzó now-30d. Una prueba que necesite nombrar uno de esos días se lo pide a fakegh.DaysAgo, y una fecha escrita a mano hace fallar TestNoFixtureWritesOutARecentDate en el acto.

Esa es la prueba correcta para un formato, y es lo bastante rápida para correr mientras se toca un destino. Lo que no puede detectar es nada sobre lo que el receptor tenga una opinión. Un servidor de captura responde 204 a todo. No tiene tipos de columna, ni mapping, ni planificador de consultas, ni esquema.

test/e2e/docker levanta los almacenes reales en contenedores, ejecuta una pasada desde el mismo GitHub falso hacia todos ellos y luego relee cada almacén y comprueba el valor, las etiquetas y sobre todo la marca de tiempo.

La regla de fechado es el producto de esta herramienta: una estrella se fecha cuando se dio, una ejecución de workflow cuando terminó, un día de tráfico en la fecha de ese día. Ninguna prueba de captura puede demostrar que el almacén guardó la fecha del hecho y no la de la pasada, porque guardarla es trabajo del almacén.

Estos son los defectos por los que existe esta capa, todos reales:

InfluxDB fija el tipo de una columna la primera vez que la ve. InfluxDB 3 decide si una columna es etiqueta o campo en cuanto la ve y rechaza toda escritura posterior que no coincida: 400 invalid column type for column 'owner', expected iox::column_type::tag. Un servidor de captura responde 204 y no se entera. Esto ya costó borrar una base de datos, y reproducirlo fue lo primero que se hizo con el stack en contenedores.

El mapping dinámico de Elasticsearch decide si los dashboards pueden agregar. Un panel saca una url con un top_metrics, que normalmente necesita un campo keyword y no uno de texto. Dos auditorías distintas lo anotaron como no verificable por falta de un Elasticsearch real. Esta capa lo responde: indexando por _bulk y releyendo el mapping que el clúster se construyó solo.

PostgreSQL tiene que aceptar el DDL. El destino SQL emite sentencias en lugar de hablar el protocolo, así que hasta ahora nadie las había parseado nunca. La suite las pasa por psql, inserta y planifica las consultas de los paneles contra el esquema que creó el destino, y no contra uno transcrito de InfluxDB.

Las rutas de Graphite deben tener la profundidad que indexan los dashboards. Los dashboards direccionan los nodos de la ruta por posición, y el acuerdo entre esas posiciones y lo que escribe el destino lo mantenía una tabla a mano que no comprobaba nadie. Aquí el destino escribe a carbon y se le pide la ruta de vuelta a la API de render.

Con los almacenes ya cargados, los cinco dashboards generados se ejecutan por /api/ds/query de Grafana, que es el camino que toma cmd/check_dashboards contra un Grafana vivo. Cada datasource se provisiona al arrancar con un uid fijo y el arnés emite un token de cuenta de servicio, así que la consulta de un panel pasa por Grafana igual que para una persona que mira el dashboard.

Eso es lo que convierte tres comprobadores manuales en algo que la CI ejecuta, y lo que zanja la duda de Elasticsearch de arriba: un panel que no puede agregar no devuelve ningún data frame.

Cada panel se envía con su rango resuelto a los dos instantes que manda un navegador, y se le hacen tres preguntas, y a cada tarjeta y cada indicador dos más, abajo: si el datasource lo rechazó, si un panel sobre algo que el barrido escribió dentro del rango respondió con algo, y si los cinco almacenes dibujan lo mismo. Para la tercera, cada respuesta se reproduce a través de lo que Grafana hace entre la consulta y la pantalla, la remodelación propia del datasource de Prometheus de una tabla, las transformaciones del panel y sus overrides de campo, y cada par de almacenes se compara en lo que vería un lector: el número de una tarjeta, su unidad y las palabras que muestra cuando no hay nada, las filas de una tabla sobre las columnas que dibujan ambos almacenes, y el nombre y la longitud de una barra. Una tabla se sujeta además a un solo orden de las columnas que dibujan ambos almacenes, cosa que las filas emparejadas sobre esas columnas no pueden mostrar: en la rama 2.6.2, 28 tablas de Prometheus y 4 de Graphite las encabezaban en otro orden que los almacenes SQL, “Every bucket” con Most used al final. La revisión de la 2.6.1 encontró once diferencias poniendo los dashboards uno junto a otro, entre ellas una tabla que dibujaba siete filas para un repositorio y tarjetas que habían perdido su unidad. Nueve estaban en los dashboards, y ejecutada contra los dashboards de esa versión esta comprobación falla en ocho. La novena eran cuatro columnas de las que carecía “Open the longest” en Elasticsearch, que ninguna comparación de valores puede ver. Lo que un almacén puede guardar es asunto de su propia descripción, así que otra aserción exige que cada columna que dibujan los almacenes SQL en una tabla la dibuje también cada otro almacén que dibuja la tabla, o que la nombren las palabras propias de ese almacén sobre el panel: en la rama 2.6.2, 33 tablas de los otros tres almacenes carecían de una columna que su descripción no nombraba, y cada una la dibuja ahora o dice por qué no.

Después se pregunta a cada tarjeta y cada indicador por nada, dos veces: por un repositorio para el que ningún barrido escribió nada, y sobre treinta días en los que no cae ningún punto de ningún barrido, de cuatrocientos días atrás o más. Sobre ninguna fila un recuento SQL es 0 y todo lo demás es nulo, que una tarjeta dibuja como las palabras que su panel da a un valor que no está, y se exige a cada almacén que dibuje las tarjetas que dibujan los almacenes SQL, con las mismas palabras. Se pregunta por el rango porque las instantáneas de la propia cuenta no siguen al selector de repositorios: sobre un rango de cuatrocientos días atrás Graphite dibujaba tres grupos de cifras como paneles sin nada, ni siquiera los nombres. Una tarjeta que un almacén no puede dibujar sobre nada se lista en tilesLeftOverNothing, en test/e2e/docker/tiles_over_nothing_test.go, con palabras de la propia descripción de ese almacén, y se sujeta a ellas igual que dashboardsDiffer.

InfluxDB y PostgreSQL ejecutan una misma sentencia sobre las mismas filas, así que también se les exige dibujar las filas de una tabla o de una gráfica de barras en un solo orden, cosa que comparar las filas como conjunto no puede ver: antes de que las sentencias nombraran su desempate, doce de los 49 paneles que ambos dibujan con más de una fila ponían dos de ellas al revés en una ejecución, y once en la siguiente. TestEverySQLListOrdersItsRowsCompletely, en internal/dashboards, exige al ORDER BY de cada lista que nombre lo que distingue dos de sus filas, para que el próximo empate no espere a una ejecución que por casualidad lo dibuje.

Dos de las reglas que se exigen a lo dibujado se exigen también a la especificación, ya que una tabla que la fixture deja vacía en los dos almacenes SQL no se dibuja nunca: cada columna que dibujan los almacenes SQL y otro almacén no, la nombra la propia descripción de ese almacén, y una gráfica que los almacenes SQL pliegan en other se pliega en cada otro almacén o dice que no lo hace (TestEveryColumnAStoreLacksIsNamedInItsDescription y TestEveryStoreFoldsTheRestIntoOtherOrSaysItDoesNot). Y una consulta de Prometheus que solo falla con series que la fixture nunca crea, un valor de etiqueta que nadie escribió o una serie que no se movió en el rango, se pone a promtool dentro del propio Prometheus del stack sobre series escritas para ella (TestPrometheusAnswersSeriesTheFixtureHasNoneOfAsTheSQLStoresDo): hasta entonces, Signed commits de una cuenta que nunca firma decía “no commits” junto a un recuento de 57.

Un almacén que dibuja un panel de otra forma a propósito dice por qué en su propia descripción del panel, y la diferencia se lista en dashboardsDiffer, en test/e2e/docker/dashboards_agree_test.go, con esas palabras. La prueba falla cuando las palabras ya no están en la descripción, cuando los almacenes han pasado a dibujar el panel igual, y cuando un almacén que la entrada nombra dibuja el panel como los almacenes que no nombra, así que una entrada no puede sobrevivir a su razón ni excusar a un almacén que no necesita excusa. Lo que causa el propio arnés se absorbe donde surge en vez de listarse: el medio minuto de historia del exporter, los valores que el colector calcula con su propio reloj en tres barridos separados por un minuto, los valores que una consulta calcula desde now() en almacenes consultados con segundos de diferencia, y los nombres que Graphite guarda como nodos de ruta. Una aserción aparte falla con cualquier panel, series temporales incluidas, que dibuje un campo con el nombre que le dio su datasource, como p50.0 seconds_to_merge.

Todas las capas corren contra el GitHub falso, así que aquí nadie se entera de que GitHub cambie una carga, retire un endpoint o limite de otra manera. Para eso está ghchronicle -once contra un token real.

Tampoco demuestran nada sobre un almacén que la suite no levanta. La respuesta cubre InfluxDB 3 Core, PostgreSQL 18, Elasticsearch 9, Graphite 1.1, Prometheus 3, Loki 3, el colector de OpenTelemetry y Telegraf, en las versiones fijadas. OpenSearch, TimescaleDB y todo lo que quede detrás del salto de Telegraf o de OTLP siguen siendo una inferencia a partir del formato.

Hay cinco cosas más, y ninguna es una capa. Cada una se enciende con una variable de entorno y se salta cuando no está, así que un go test ./... corriente sigue sin red.

El almacén que tú ejecutas. test/live envía por push un puñado de puntos a un Loki o a un colector de OpenTelemetry nombrados en GHC_LIVE_LOKI o GHC_LIVE_OTLP. Responde a la única pregunta que los contenedores no pueden: si tu instancia los acepta.

Ventana de terminal
GHC_LIVE_LOKI=http://localhost:3100 go test ./test/live/

La API real, de extremo a extremo. GHC_E2E_LIVE=1 corre TestLiveAPI contra GitHub en vez de contra el falso, con un GITHUB_TOKEN de verdad, y hace la pasada sobre la cuenta que nombra GHC_E2E_USER.

Ventana de terminal
GHC_E2E_LIVE=1 GHC_E2E_USER=octocat GITHUB_TOKEN=ghp_... go test ./test/e2e/ -run TestLiveAPI

Lo que cuesta una pasada en caché. GHC_LIVE_CONFIG apunta TestLiveSweepCacheFootprint a un fichero de configuración y hace la pasada sobre la cuenta que nombra, informando de las entradas y los bytes que guarda la caché de peticiones condicionales tras cada pasada. Esas son las cifras sobre las que descansan la cota de 256 MB y el coste de una pasada, y así es como se reproducen para tu propia cuenta. GHC_LIVE_DUMP=1 añade la lista por url a la salida estándar.

Ventana de terminal
GHC_LIVE_CONFIG=config.yaml go test ./internal/ghapi/ -run TestLiveSweepCacheFootprint -v

Un repositorio, una familia. cmd/probe corre los colectores contra un solo repositorio e imprime la línea que escribirían, sin escribir nada en ningún sitio. GHC_DUMP=<familia> imprime entero cada punto de esa familia, que es la forma más rápida de ver lo que produce de verdad un colector.

Ventana de terminal
go run ./cmd/probe owner/name
GHC_DUMP=actions go run ./cmd/probe owner/name

Las imágenes de la tarjeta. GHC_CARD_GALLERY nombra un directorio que ya existe y TestCardGallery dibuja en él una tarjeta por diseño, desde el GitHub falso y no desde la cuenta de nadie. De ahí salen las imágenes de la página de diseños, y un diseño que cambia de forma queda a un comando de tener unas imágenes que concuerden con él. La cuenta son los fixtures base con test/e2e/testdata/gallery/ superpuesto: un año de contribuciones, los catorce días completos de tráfico de GitHub, cinco repositorios que ordenar y uno de ellos en seis lenguajes, que la cuenta más pequeña sobre la que afirma el resto de suites no puede dar a una imagen. Un fixture llamado <repo>~<fixture> responde solo por ese repositorio, y cualquier otro toma prestado el de hello-world. Cada diseño sale de una sola pasada con -card-theme both como dos ficheros, card-<diseño>.svg en la paleta clara y card-<diseño>_dark.svg en la oscura, que es lo que leen el ThemeImage del sitio y el <picture> del README. Los dos diseños que hacen bucle salen una segunda vez con -card-motion loop, como card-<diseño>-loop.svg y su gemela _dark. Solo esos dos: en cualquier otro diseño loop dibuja la misma tarjeta que once, así que una imagen en bucle suya sería una segunda copia de la primera con un nombre que promete otra cosa. Cuáles son lo dice Loops en el registro, y la galería lo lee en vez de mantener su propia lista.

Ventana de terminal
mkdir -p /tmp/cards
GHC_CARD_GALLERY=/tmp/cards go test ./test/e2e/ -run TestCardGallery

make check-gallery dibuja la galería en un directorio temporal y falla, nombrando cada diferencia, si el conjunto guardado en el repositorio ya no coincide byte a byte; make gallery la regenera en su sitio. El job «Generated artifacts» de CI ejecuta la comprobación en cada pull request.

Docker con el plugin de compose, y sitio para las imágenes. Luego:

Ventana de terminal
make test-e2e-docker

Arriba, ejecutar, abajo por todos los caminos incluido el de una comprobación fallida, y después una verificación de que docker ps no deja nada del proyecto. Una suite que deja nueve contenedores tras un fallo es una suite que nadie ejecuta dos veces.

Arranque, medido en frío con las imágenes ya descargadas: Elasticsearch 29 s, Loki 21 s, Grafana 13 s, la API de render de Graphite 10 s, InfluxDB 8 s, PostgreSQL 6 s, el resto 6 s. El stack está listo en 30 s; el objetivo de principio a fin, con el desmontaje incluido, son 53 s.

El motivo para fallar una comprobación es ir a mirar el almacén, y un almacén que ya se ha desmontado no se puede mirar. Por eso las dos mitades son objetivos separados, y el arnés reutiliza un stack que encuentra ya levantado y lo deja levantado.

  1. Levanta los almacenes y déjalos en pie. La orden imprime el puerto en el que acabó cada servicio.

    Ventana de terminal
    make e2e-docker-up
  2. Ejecuta la suite, o una sola prueba de ella, tantas veces como haga falta.

    Ventana de terminal
    go test -count=1 -tags dockere2e -timeout 30m -v ./test/e2e/docker/

    GHCHRONICLE_E2E_KEEP=1 además impide que el binario de pruebas desmonte un stack que levantó él mismo, que es lo que quieres cuando falla un único -run.

  3. Pregúntale al almacén qué piensa y luego desmóntalo.

    Ventana de terminal
    make e2e-docker-logs SERVICE=influxdb
    make e2e-docker-down

Con los puertos del paso 1:

Ventana de terminal
# Qué cree InfluxDB que es cada columna. Esta es la respuesta a un 400 al escribir.
curl -s "http://127.0.0.1:<influx>/api/v3/query_sql?db=ghchronicle" \
--data-urlencode "q=SELECT * FROM information_schema.columns WHERE table_name = 'gh_repo'"
# El mapping que Elasticsearch se construyó solo.
curl -s "http://127.0.0.1:<es>/ghchronicle-*/_mapping?pretty"
# Lo que el destino SQL creó de verdad.
psql "postgres://ghchronicle:ghchronicle@127.0.0.1:<pg>/ghchronicle" -c '\d+ gh_repo'
# La ruta de Graphite, nodo a nodo.
curl -s "http://127.0.0.1:<graphite>/metrics/find?query=github.repo.*"

Grafana está en el puerto que publicó, con admin y admin, y todos los datasources vienen provisionados, así que la consulta de un panel se puede pegar en Explore y ejecutar a mano.

Cada una de estas produjo en silencio una respuesta equivocada antes de que se encontrara, y cada una está en el fichero de compose o en su configuración con la medición al lado:

  • Carbon descarta sin avisar un punto más antiguo que su archivo más largo. Una estrella fechada en 2020 desapareció bajo una retención de seis años y la escritura se dio por aceptada. Por eso la retención es 1d:12y.
  • El MAX_CREATES_PER_MINUTE por defecto de carbon es 50, menos rutas de las que crea una sola pasada, así que se perdería la mayor parte de la primera.
  • Loki responde 204 a un push y no lo sirve hasta que se vuelca el chunk. La prueba sondea en vez de preguntar una vez, y chunk_idle_period es 5 s.

Una máquina que añade la regla ejecuta esas comprobaciones por primera vez, que es cuando promNeedsHistory en dashboards_test.go empieza a importar: el exportador vive solo lo que dura el test, así que cada panel de series temporales y cada panel construido sobre increase() se comprueba contra nada, y solo se afirman los paneles instantáneos.

.github/workflows/e2e.yml ejecuta make test-e2e-docker y tiene tres formas de entrar: lanzamiento manual con una ref opcional, una programación semanal sobre main y workflow_call, para que un pipeline de publicación condicione una etiqueta a él con una línea en vez de con una copia del trabajo que se desvía del original.

No es una comprobación obligatoria en una pull request: nueve contenedores y unos 10 GB de imágenes son demasiado para cada push, y para eso está L1. La ejecución semanal es el sentido de la programación. Nada más en el repositorio levanta un contenedor, así que sin ella la suite solo correría cuando alguien se acordara, que es como una suite acaba rota durante semanas sin que nadie lo sepa.

La misma suite corre también bajo el detector de carreras, en .github/workflows/race.yml: cada semana, en cada publicación junto a la puerta de E2E, y a mano. El arnés compila el colector con -race y lo arranca con GORACE=halt_on_error=1, así que una carrera dentro del colector hace fallar la prueba que lo lanzó, con el informe. En local es make test-e2e-docker-race.