In an ideal world, you would write proofs instead of tests. For example, consider the following functions.
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);
}
};
Say you want to prove that transform applied twice is idempotent, i.e. for all valid inputs x, transform(transform(x)) is equal to x. Well, you would first need to prove that negate and reverse applied twice are idempotent. Now, suppose that proving the idempotence of negate and reverse applied twice is trivial, i.e. the compiler can figure it out. Thus, we have the following lemmas.
const negateNegateIdempotent = (x: number): negate(negate(x))≡x => refl;
const reverseReverseIdempotent = (x: string): reverse(reverse(x))≡x => refl;
We can use these two lemmas to prove that transform is idempotent as follows.
const transformTransformIdempotent = (x: number|string): transform(transform(x))≡x => {
switch (typeof x) {
case "number": return negateNegateIdempotent(x);
case "string": return reverseReverseIdempotent(x);
}
};
There's a lot going on here, so let's break it down.
- Just as
a|b is a union type and a&b is an intersection type, a≡b is an equality type.
- A value
x of an equality type a≡b is a proof of the equality of a and b.
- If two values,
a and b, are not equal then it's impossible to construct a value of type a≡b.
- The value
refl, short for reflexivity, has the type a≡a. It's the trivial proof of a value being equal to itself.
- We used
refl in the proof of negateNegateIdempotent and reverseReverseIdempotent. This is possible because the propositions are trivial enough for the compiler to prove automatically.
- We use the
negateNegateIdempotent and reverseReverseIdempotent lemmas to prove transformTransformIdempotent. This is an example of a non-trivial proof.
The advantage of writing proofs is that the compiler verifies the proof. If the proof is incorrect, then the program fails to type check and the compiler throws an error. Proofs are better than tests for two reasons. First, you don't have to create test data. It's difficult to create test data that handles all the edge cases. Second, you won't accidentally forget to test any edge cases. The compiler will throw an error if you do.
Unfortunately, TypeScript doesn't have an equality type because it doesn't support dependent types, i.e. types that depend upon values. Hence, you can't write proofs in TypeScript. You can write proofs in dependently typed functional programming languages like Agda.
However, you can write propositions in 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);
}
};
You can then use a library such as jsverify to automatically generate test data for multiple test cases.
const jsc = require("jsverify");
jsc.assert(jsc.forall("number", transformTransformIdempotent)); // OK, passed 100 tests
jsc.assert(jsc.forall("string", transformTransformIdempotent)); // OK, passed 100 tests
You can also call jsc.forall with "number | string" but I can't seem to get it to work.
So to answer your questions.
How should one go about testing foo()?
Functional programming encourages property-based testing. For example, I tested the negate, reverse, and transform functions applied twice for idempotence. If you follow property-based testing, then your proposition functions should be similar in structure to the functions that you're testing.
Should you treat the fact that it delegates to fnForString() and fnForNumber() as an implementation detail, and essentially duplicate the tests for each of them when writing the tests for foo()? Is this repetition acceptable?
Yes, is it acceptable. Although, you can entirely forego testing fnForString and fnForNumber because the tests for those are included in the tests for foo. However, for completeness I would recommend including all the tests even if it introduces redundancy.
Should you write tests which "know" that foo() delegate to fnForString() and fnForNumber() e.g. by mocking them out and checking that it delegates to them?
The propositions that you write in property-based testing follows the structure of the functions you're testing. Hence, they "know" about the dependencies by using the propositions of the other functions being tested. No need to mock them. You'd only need to mock things like network calls, file system calls, etc.