Em um mundo ideal, você escreveria provas em vez de testes. Por exemplo, considere as seguintes funções.
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 você queira provar que o transform
aplicado duas vezes é idempotente , ou seja, para todas as entradas válidas x
, transform(transform(x))
é igual a x
. Bem, você primeiro precisa provar isso negate
e reverse
aplicar duas vezes é idempotente. Agora, suponha que provar a idempotência de negate
e reverse
aplicado duas vezes seja trivial, ou seja, o compilador pode descobrir isso. Assim, temos os seguintes lemas .
const negateNegateIdempotent = (x: number): negate(negate(x))≡x => refl;
const reverseReverseIdempotent = (x: string): reverse(reverse(x))≡x => refl;
Podemos usar esses dois lemas para provar que transform
é idempotente da seguinte maneira.
const transformTransformIdempotent = (x: number|string): transform(transform(x))≡x => {
switch (typeof x) {
case "number": return negateNegateIdempotent(x);
case "string": return reverseReverseIdempotent(x);
}
};
Há muita coisa acontecendo aqui, então vamos detalhar.
- Assim como
a|b
um tipo de união e a&b
um tipo de interseção, a≡b
é um tipo de igualdade.
- Um valor
x
de um tipo de igualdade a≡b
é uma prova da igualdade de a
e b
.
- Se dois valores,
a
e b
, não são iguais, então é impossível construir um valor do tipo a≡b
.
- O valor
refl
, abreviação de reflexividade , tem o tipo a≡a
. É a prova trivial de um valor ser igual a si mesmo.
- Usamos
refl
na prova de negateNegateIdempotent
e reverseReverseIdempotent
. Isso é possível porque as proposições são triviais o suficiente para o compilador provar automaticamente.
- Usamos os lemas
negateNegateIdempotent
e reverseReverseIdempotent
para provar transformTransformIdempotent
. Este é um exemplo de uma prova não trivial.
A vantagem de escrever provas é que o compilador verifica a prova. Se a prova estiver incorreta, o programa falhará em check e o compilador emitirá um erro. As provas são melhores que os testes por dois motivos. Primeiro, você não precisa criar dados de teste. É difícil criar dados de teste que lidem com todos os casos extremos. Segundo, você não esquecerá acidentalmente de testar casos extremos. O compilador lançará um erro se você o fizer.
Infelizmente, o TypeScript não possui um tipo de igualdade porque não suporta tipos dependentes, ou seja, tipos que dependem de valores. Portanto, você não pode escrever provas no TypeScript. Você pode escrever provas em linguagens de programação funcional de tipo dependente, como o Agda .
No entanto, você pode escrever proposições no 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);
}
};
Em seguida, você pode usar uma biblioteca como jsverify para gerar automaticamente dados de teste para vários casos de teste.
const jsc = require("jsverify");
jsc.assert(jsc.forall("number", transformTransformIdempotent)); // OK, passed 100 tests
jsc.assert(jsc.forall("string", transformTransformIdempotent)); // OK, passed 100 tests
Você também pode ligar jsc.forall
com "number | string"
, mas eu não consigo fazê-lo funcionar.
Então, para responder suas perguntas.
Como se deve testar foo()
?
A programação funcional incentiva o teste baseado em propriedades. Por exemplo, o I testado negate
, reverse
e transform
funções aplicado duas vezes por idempotência. Se você seguir o teste baseado em propriedades, suas funções de proposição deverão ter estrutura semelhante às funções que você está testando.
Você deve tratar o fato de que ele delega para fnForString()
e fnForNumber()
como um detalhe de implementação e essencialmente duplicar os testes para cada um deles ao escrever os testes foo()
? Esta repetição é aceitável?
Sim, é aceitável. No entanto, você pode renunciar inteiramente aos testes fnForString
e fnForNumber
porque os testes para esses estão incluídos nos testes para foo
. No entanto, para completar, eu recomendaria incluir todos os testes, mesmo que isso introduza redundância.
Você deve escrever testes que "saibam" que foo()
delegam fnForString()
e, fnForNumber()
por exemplo, zombando deles e verificando se ele delega a eles?
As proposições que você escreve nos testes baseados em propriedades seguem a estrutura das funções que você está testando. Portanto, eles "sabem" sobre as dependências usando as proposições das outras funções que estão sendo testadas. Não há necessidade de zombar deles. Você só precisa zombar de coisas como chamadas de rede, chamadas do sistema de arquivos etc.