# Las capas de prueba

Tres capas, y solo la primera es gratis: el contrato de los bytes, los almacenes reales en contenedores y las consultas propias de los dashboards.

Source: https://docs.jmrp.io/ghchronicle/es/reference/testing/

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.

| Capa               | Orden                  | Docker | Tiempo           | Qué demuestra                                                                                   |
| ------------------ | ---------------------- | ------ | ---------------- | ----------------------------------------------------------------------------------------------- |
| L1, el contrato    | `make test`            | no     | unos 5 s         | los bytes exactos que cada destino pone en el cable                                             |
| L2, los almacenes  | `make test-e2e-docker` | sí     | 53 s en caliente | que un almacén real acepta esos bytes y conserva la fecha del hecho                             |
| L3, los dashboards | el mismo objetivo      | sí     | incluido arriba  | que 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.

## L1: los bytes en el cable

`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.

## L2: los almacenes de verdad

`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.

## L3: las consultas propias de los dashboards

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`.

## Lo que no detecta ninguna

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.

```sh
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`.

```sh
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](https://docs.jmrp.io/ghchronicle/es/api/cost/), 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.

```sh
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.

```sh
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](https://docs.jmrp.io/ghchronicle/es/card/layouts/), 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.

```sh
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.

## Levantar el stack

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

```sh
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.

> **Nada de esto escucha fuera de loopback**
>
> Cada puerto se publica en `127.0.0.1`, en un puerto libre que Docker elige
> entre 49200 y 49299, nunca el puerto por defecto del almacén: 9200, 8086,
> 5432, 2003, 9090, 3100, 3000 y 4318 son de lo que ya haya en la máquina. El
> arnés relee los puertos elegidos con `docker compose port`, y el proyecto se
> llama `ghchronicle-e2e` tanto en el fichero de compose como en cada orden,
> así que nada de aquí puede tocar un contenedor que no arrancó.

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.

## Depurar con el stack en pie

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.

    ```sh
    make e2e-docker-up
    ```

2. Ejecuta la suite, o una sola prueba de ella, tantas veces como haga falta.

    ```sh
    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.

    ```sh
    make e2e-docker-logs SERVICE=influxdb
    make e2e-docker-down
    ```

Con los puertos del paso 1:

```sh
# 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.

## Tres cosas que hubo que decirle al stack

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 con cortafuegos necesita una regla para hacer scrape del exportador**
>
> El exportador de Prometheus es el único destino del que se hace scrape en
> lugar de recibir envíos, así que el scrape tiene que salir del contenedor de
> vuelta al anfitrión. Donde la política INPUT por defecto es denegar, ningún
> contenedor de ningún puente alcanza ningún puerto del anfitrión. Basta con una
> regla estrecha: permitir TCP 49300-49399, el rango que acotan `scrapePortLow`
> y `scrapePortHigh` en `sweep_push_stores_test.go`, desde el espacio de
> direcciones de los contenedores y desde nada más. Con ufw es
>
> ```sh
> ufw allow proto tcp from 172.16.0.0/12 to any port 49300:49399
> ```
>
> Sin ella el arnés lo informa como `ErrExporterUnreachable` y las
> comprobaciones de Prometheus se saltan con un motivo en lugar de fallar, lo
> que significa que nunca se han ejecutado en esa máquina. Un runner de CI no
> necesita regla, y una máquina de trabajo normal tampoco.

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.

## En CI

`.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`.
