En un mundo ideal, escribirías pruebas en lugar de pruebas. Por ejemplo, considere las siguientes funciones.
const negate = (x: number): number => -x;
const reverse = (x: string): string => x.split("").reverse().join("");
const transform = (x: number|string): number|string => {
switch (typeof x) {
case "number": return negate(x);
case "string": return reverse(x);
}
};
Digamos que quiere demostrar que transform
aplicado dos veces es idempotente , es decir, para todas las entradas válidas x
, transform(transform(x))
es igual a x
. Bueno, primero deberías probar que negate
y reverse
aplicado dos veces son idempotentes. Ahora, suponga que probar la idempotencia negate
y reverse
aplicar dos veces es trivial, es decir, el compilador puede resolverlo. Por lo tanto, tenemos los siguientes lemas .
const negateNegateIdempotent = (x: number): negate(negate(x))≡x => refl;
const reverseReverseIdempotent = (x: string): reverse(reverse(x))≡x => refl;
Podemos usar estos dos lemas para demostrar que transform
es idempotente de la siguiente manera.
const transformTransformIdempotent = (x: number|string): transform(transform(x))≡x => {
switch (typeof x) {
case "number": return negateNegateIdempotent(x);
case "string": return reverseReverseIdempotent(x);
}
};
Están sucediendo muchas cosas aquí, así que analicemos.
- Así como
a|b
es un tipo de unión y a&b
es un tipo de intersección, a≡b
es un tipo de igualdad.
- Un valor
x
de un tipo de igualdad a≡b
es una prueba de la igualdad de a
y b
.
- Si dos valores,
a
y b
, no son iguales, entonces es imposible construir un valor de tipo a≡b
.
- El valor
refl
, la abreviatura de reflexividad , tiene el tipo a≡a
. Es la prueba trivial de que un valor es igual a sí mismo.
- Usamos
refl
en la prueba de negateNegateIdempotent
y reverseReverseIdempotent
. Esto es posible porque las proposiciones son lo suficientemente triviales para que el compilador las pruebe automáticamente.
- Usamos los
negateNegateIdempotent
y reverseReverseIdempotent
lemas para probar transformTransformIdempotent
. Este es un ejemplo de una prueba no trivial.
La ventaja de escribir pruebas es que el compilador verifica la prueba. Si la prueba es incorrecta, el programa no puede escribir check y el compilador arroja un error. Las pruebas son mejores que las pruebas por dos razones. Primero, no tiene que crear datos de prueba. Es difícil crear datos de prueba que manejen todos los casos límite. En segundo lugar, no olvidará accidentalmente probar los casos límite. El compilador arrojará un error si lo haces.
Desafortunadamente, TypeScript no tiene un tipo de igualdad porque no admite tipos dependientes, es decir, tipos que dependen de valores. Por lo tanto, no puede escribir pruebas en TypeScript. Puede escribir pruebas en lenguajes de programación funcional de tipo dependiente como Agda .
Sin embargo, puede escribir proposiciones en TypeScript.
const negateNegateIdempotent = (x: number): boolean => negate(negate(x)) === x;
const reverseReverseIdempotent = (x: string): boolean => reverse(reverse(x)) === x;
const transformTransformIdempotent = (x: number|string): boolean => {
switch (typeof x) {
case "number": return negateNegateIdempotent(x);
case "string": return reverseReverseIdempotent(x);
}
};
Luego puede usar una biblioteca como jsverify para generar automáticamente datos de prueba para múltiples casos de prueba.
const jsc = require("jsverify");
jsc.assert(jsc.forall("number", transformTransformIdempotent)); // OK, passed 100 tests
jsc.assert(jsc.forall("string", transformTransformIdempotent)); // OK, passed 100 tests
También puede llamar jsc.forall
a "number | string"
pero me parece que no puede conseguir que funcione.
Entonces para responder a sus preguntas.
¿Cómo se debe hacer una prueba foo()
?
La programación funcional fomenta las pruebas basadas en propiedades. Por ejemplo, he probado el negate
, reverse
y transform
Funciones de aplicación dos veces para idempotencia. Si sigue las pruebas basadas en propiedades, las funciones de su propuesta deben ser similares en estructura a las funciones que está probando.
¿Debería tratar el hecho de que delega fnForString()
y fnForNumber()
como un detalle de implementación, y esencialmente duplicar las pruebas para cada una de ellas al escribir las pruebas foo()
? ¿Es aceptable esta repetición?
Sí, es aceptable Sin embargo, puede renunciar por completo a las pruebas fnForString
y fnForNumber
porque las pruebas para esos están incluidas en las pruebas para foo
. Sin embargo, para completar, recomendaría incluir todas las pruebas incluso si introduce redundancia.
¿Debería escribir pruebas que "sepan" que foo()
delegan fnForString()
y, fnForNumber()
por ejemplo, burlándose de ellas y verificando que deleguen en ellas?
Las proposiciones que escribe en las pruebas basadas en propiedades siguen la estructura de las funciones que está probando. Por lo tanto, "saben" acerca de las dependencias utilizando las proposiciones de las otras funciones que se están probando. No hay necesidad de burlarse de ellos. Solo necesitaría burlarse de cosas como llamadas de red, llamadas al sistema de archivos, etc.