CLI y librería en Go · pruebas de mutación · se ejecuta en el ciclo TDD

ditto

Pruebas de mutación lo bastante baratas como para ejecutarlas mientras todavía escribes el código.

Rompe tu código a propósito, un operador a la vez, y reporta cada mutación que tus pruebas dejaron pasar. Instala el comando y `ditto staged` lee el índice de git, deduce por su cuenta qué líneas se movieron y te cobra solo por esas. Cada afirmación que hace sobre velocidad viaja con el contador que la refutaría.

15
operadores de mutación
12×
menos ejecuciones, acotado
3.5×
menos invocaciones, gated
8
contadores que cierran el build
ditto runRecortado
┃ Releasing Ditto…┃ calc/calc.go — 7 mutants┃   calc/calc.go:9:12 → Arithmetic┃ baseline: the suite took <duration> on unmutated code, and every mutant runs it again.┃   calc/calc.go:12:11 → Arithmetic┃   calc/calc.go:16:41 → Arithmetic┃   calc/calc.go:4:41 → Comparison┃   calc/calc.go:8:8 → Comparison┃   calc/calc.go:4:40 → Comparison Invert┃   calc/calc.go:8:7 → Comparison Invert┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╍┅┃ 🧬 Survivors┠┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┃ calc/calc.go:12:11 → Arithmetic (- → +)┃ calc/calc.go:16:41 → Arithmetic (+ → -)┃ calc/calc.go:8:8 → Comparison (inserts =)┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╍┅┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╍┅┃ 🧬 Mutant survived: calc/calc.go:12:11 → Arithmetic┠┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┃ --- calc/calc.go (original)┃ +++ calc/calc.go (mutated with 'Arithmetic')┃ @@ -9,7 +9,7 @@┃  		return a - b┃  	}┃ -	return b - a┃ +	return b + a┃  }┃  // Uncovered is never called, so every mutant of it lives.┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╍┅ … two further survivor diffs elided … ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┃ • Total:        7                    ┃┃ • Killed:       4                    ┃┃ • Survived:     3                    ┃┠┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┨┃ ✓ Score:     0.57 (minimum: 0.00)    ┃┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
El reporte golden que imprime `ditto run`, byte por byte. Cada mutante se nombra justo antes de ejecutarse, así que una demora dice dentro de qué mutante ocurre. El reloj es el único valor que un fixture no puede fijar, por eso el archivo lo normaliza a `<duration>`; el resto de los caracteres son de la herramienta.
Cuánto cuesta una ejecución

Cada mutante es una ejecución entera de tu comando de pruebas

Arrancar el comando de pruebas cuesta entre 750 y 950 milisegundos por mutante, haga lo que haga la suite, y ese peaje domina la ejecución. Al compilar una sola vez y elegir el mutante en tiempo de ejecución, se paga una vez para toda la ejecución: los mismos doce mutantes vuelven en 0.4 a 1.3 segundos frente a 10 u 11. Los dos mecanismos de abajo recortan cuántas veces se invoca el comando.

Archivo fuenteAnalizado unavez4× por release, unopor archivoLos operadoresrecorren el ASTMutantesSandbox,construido unavez1× por release,reutilizado entremutantesComando depruebas pormutante49 invocaciones: unapor mutante, más unabaseMuerto osuperviviente
  • WithChangedRanges

    Paga por las líneas que el cambio realmente tocó

    484ejecuciones de pruebas
    8% de la cuenta original

    Entrega los rangos de bytes que cubre un cambio, indexados por archivo, y la release muta solo esos. O ejecuta `ditto staged`, que los lee por ti del índice de git. Sobre el fixture de referencia, una función modificada cobra los cuatro operadores que disparan en esa línea y nada más del repositorio.

    Escala con tu cambio: una función modificada en cada uno de dos archivos cobra ocho ejecuciones, exactamente el doble de lo que cobra un archivo, porque a cada archivo solo se le cobran sus propios rangos.

  • Gated

    Una sola compilación sostiene todos los mutantes del archivo

    13538invocaciones del comando
    28% de la cuenta original

    El archivo se instrumenta para que cada mutante sea una compuerta elegida en tiempo de ejecución, el paquete se compila una sola vez con `go test -c` y cada mutante se selecciona por variable de entorno. Un archivo que la instrumentación no puede sostener vuelve solo a la ruta ordinaria, y la ejecución sigue sobre el resto del repositorio.

    Donde las dos rutas difieren, la de compuertas es la que tiene razón: puntúa un mutante que la compilación ordinaria no puede construir.

Gated

Las dos rutas devuelven los mismos veredictos

VeredictoOrdinariaCompuertas
Muertos127127
Supervivientes88
135
mutantes puntuados
6
ejecuciones
1 / 35
tasa de desacuerdo
Proporción de un repositorio real que la compuerta sostuvo
26%72%

Cuánto vale la ganancia depende de tu suite: el mismo mecanismo produjo 2.7× en un paquete cuya suite tarda 243 ms y 1.5× en uno que tarda 1155 ms, porque el peaje que elimina es un costo fijo.

El veredicto

Un puntaje sobre el que puedes actuar, o un rechazo que dice por qué

ditto lee el destino de un mutante a partir de un comando de pruebas que falla. Si la suite ya está roja, todos los mutantes parecen muertos y la ejecución reporta un puntaje perfecto. Todo lo que sigue es lo que se interpone entre ese número y tú.

Muertes reportadas, por motivo50 de 78 mutantes reportados como muertos
Le acredita a tu suite
Aserción
Tu suite no recibe crédito22%
Fallo de compilaciónPlazo vencidoDesconocido
  • La suite se prueba en verde antes de puntuar nada

    Una ejecución base de tu comando sobre el código sin mutar abre cada release, y si sale en rojo la termina ahí, con la salida del propio comando, así que puedes leer el motivo sin entrar al sandbox. Esa ejecución además anuncia cuánto costó la suite, que es el precio que cada mutante vuelve a pagar.

  • La ruta gated lee un veredicto que ya estaba pagando

    Ejecuta el archivo instrumentado sin ningún mutante seleccionado, que es la suite propia del archivo: una medición ya pagada, y ahora leída. Sobre los mismos mutantes separa un reporte de 4 muertos de 4 del verdadero 1 de 4, y esa lectura es gratis.

  • Cada muerte llega con el motivo por el que ocurrió

    `internal/verdict` lo lee del flujo de eventos de `go test -json` que la ejecución ya produce, y los cuatro motivos que puede devolver son los que se dibujan al inicio de esta sección.

  • El puntaje se toma sobre los mutantes que compilaron

    Un mutante que nunca llegó a ser un programa sale del puntaje por completo, del numerador y del denominador, así que lo que lees es la proporción de mutantes ejecutables que tus pruebas atraparon. Sobre un fixture donde dos de cinco mutantes no compilaron, eso es 0.33.

  • Todos los archivos se miden, sea cual sea la ruta que tomen

    Cuando las compuertas están activas y un archivo no sostiene el esquema, ese archivo vuelve solo a la ruta ordinaria y la ejecución sigue sobre todo lo demás. En ditto mismo exactamente un archivo lo necesitó, y el resto conservó su única compilación.

  • Cada superviviente llega con una dirección

    `ruta:línea:columna → Operador (lo que reemplazó)`, listado antes de cualquier diff, para poder saltar directo a un superviviente. Es además lo que distingue a dos supervivientes: sobre 135 mutantes de cuatro archivos con gofmt, 129 son indistinguibles de otro mutante en todo salvo en su dirección y el texto que reemplazaron.

  • El reporte entero está fijado, byte por byte

    Una prueba golden compara una release completa contra un fixture que tiene tanto muertes como supervivientes, en la ruta ordinaria y en la gated, así que a las dos se les exige imprimir lo mismo.

Cuatro preguntas que el build hace sobre una ejecución

¿Qué proporción de los mutantes de un operador nunca compila?
Qué operador arreglar.
¿Cada muerte lleva un motivo?
Un veredicto que puedes auditar.
¿Cada veredicto cae dentro del cambio que pediste?
Una ejecución que se queda en el alcance que le diste.
¿El cambio que estás haciendo le cuesta más a la compuerta?
Qué le hace este cambio al precio de la compuerta.
Los contadores

El costo de una release está fijado a un número exacto

Cada número de abajo es un entero exacto: un conteo de análisis, sandboxes, archivos e invocaciones del comando de pruebas, idéntico en cualquier máquina. Cada uno está fijado, y el build falla si se mueve en cualquier dirección, así que lo que una release cuesta hoy es lo que te costará la próxima semana.

ContadorEraEs
sourceParsesPerReleaseWithThreeVirusesUn análisis sintáctico por archivo fuente, compartido entre todos los operadores: un archivo se lee una vez, sin importar cuántos de los catorce operadores por defecto estén activos.124
sandboxesBuiltPerReleaseLos sandboxes se agrupan y el archivo mutado se restaura antes de devolver uno, así que una ejecución secuencial construye exactamente uno y una paralela construye tantos como su concurrencia máxima.481
filesLinkedPerSandboxSeis archivos de trabajo: un sandbox tiene el árbol sin `.git`. El conteo es el mismo sin importar cómo llega cada archivo (copiado, con enlace duro o con enlace simbólico), a unos 0.45 ms por archivo, y se paga una vez por release.116
laboratoryRunsForOneChangedFunctionCuatro ejecuciones por una función modificada: los operadores que disparan en esa línea y nada más del repositorio.484
laboratoryRunsForOneChangedFunctionInEachOfTwoFilesUna función modificada en cada uno de dos archivos cuesta exactamente el doble que una, porque a cada archivo se le cobran solo sus propios rangos.488
mutantsPerReleaseOnThisRepositoryLo que paga una ejecución completa sobre el propio repositorio de ditto: 785 mutantes. Se mide contra el repositorio mismo, así que es la cifra que dice cuánto cuesta una ejecución de ese tamaño.431785
testCommandInvocationsPerReleaseWholeFixtureCuarenta y ocho mutantes, más la única ejecución base de tu suite sobre el código sin mutar que hace que el puntaje signifique algo. Una base para toda la release.4849

El tiempo de reloj se mide y se reporta: en una máquina de desarrollo real, la misma carga de trabajo varió aquí más de un cincuenta por ciento entre ejecuciones, mientras que los conteos de arriba se mantuvieron exactos.

Los operadores

Quince formas de romper tu código a propósito

Cada uno edita el árbol sintáctico como lo haría un error real: un operador invertido, una constante desplazada, un bucle interrumpido. Catorce están activos antes de que configures nada, desde el comando o desde `Release`; el decimoquinto se agrega nombrándolo.

  • Arithmetic
    • +-
    • */
    • %*
  • Arithmetic Assignment
    • +=-=*=/=%=&=|=^=<<=>>=&^==
  • Arithmetic Assignment Invert
    • +=-=
    • *=/=
    • %=*=
  • Bitwise
    • &|
    • ^&
    • &^&
    • <<>>
  • Comparison
    • <<=
    • >>=
  • Comparison Invert
    • ><=
    • <>=
    • ==!=
  • Comparison Replace
    • &&operandtrue
    • ||operandfalse
  • Float Decrement
    • xx-1.0
  • Float Increment
    • xx+1.0
  • Integer Decrement
    • nn-1
  • Integer Increment
    • nn+1
  • Loop Break
    • breakcontinue
  • Loop Condition
    • conditionfalse
  • Range Break
    • rangeearlybreak
  • Cancel NilOpcional
    • context.CancelCauseFunc(err)(nil)

Escribe uno para tu propio dominio

Un virus es cualquier struct que satisfaga la interfaz `viruses.Virus`, así que una mutación que solo significa algo en tu código es un tipo y una llamada a `WithViruses`. El paquete `dittotesting` trae los ayudantes con los que se prueban los quince que vienen incluidos, para que el tuyo reciba el mismo trato.

Agregarlo

Instala el comando o importa la librería

El comando es la forma más corta de empezar: instálalo una vez y `ditto staged` muta solo lo que justifica tu cambio preparado, con `--dry` para cotizarlo antes de pagarlo. La librería es el mismo motor desde un binario de pruebas que ya tienes.

Desde la línea de comandos

Cada mutante se juzga con el comando de pruebas de abajo, y `-json` es lo que permite que ditto diga por qué murió.

$ go install github.com/Disble/ditto/cmd/ditto@latest
Después ejecuta
$ ditto staged --threshold 0.8
El comando que ejecuta por mutante
$ go test -count=1 -json ./...
Desde un binario de pruebas

Una importación y una etiqueta de compilación ponen el mismo motor dentro de la suite que ya ejecutas, y con `WithTestCommand` indicas otro comando de pruebas.

$ go get github.com/Disble/ditto
Después ejecuta
$ go test -v -tags=mutation
El comando que ejecuta por mutante
$ go test -count=1 -json ./...
Abrir el repositorio
ditto -hSalida capturada
ditto — mutation testing for Go   ditto run [flags]       mutate a repository and report what survived  ditto staged [flags]    mutate only what a staged change justifies  ditto changed [flags]   mutate only what a committed change justifies  ditto version           the module version this binary was built from Run `ditto run -h` for its flags.
mutation_test.go
//go:build mutation package main_test import (	"testing" 	"github.com/Disble/ditto") func TestMutation(t *testing.T) {	ditto.Release(t)}
  • Disble/ditto

    El código, la bitácora fechada y la línea base de rendimiento fijada.

  • pkg.go.dev/Disble/ditto

    Cada opción, cada operador, la interfaz `viruses.Virus` y los puntos de entrada `RunStaged` y `RunChanged`, generados desde el código.

  • dharness

    La puerta de commit que entrega sus propios cambios de Go preparados directamente a `ditto staged`.

ditto es un fork de gtramontina/ooze, de Guilherme J. Tramontina. Todas las buenas ideas de aquí son suyas, y la licencia y el copyright siguen siendo de él; es MIT, igual que el original.