Suppose you have function which takes a union type and then narrows the type and delegate to one of two other pure functions.
function foo(arg: string|number) {
if (typeof arg === 'string') {
return fnForString(arg)
} else {
return fnForNumber(arg)
}
}
Assume that fnForString()
and fnForNumber()
are also pure functions, and they have already themselves been tested.
How should one go about testing foo()
?
- Should you treat the fact that it delegates to
fnForString()
andfnForNumber()
as an implementation detail, and essentially duplicate the tests for each of them when writing the tests forfoo()
? Is this repetition acceptable? - Should you write tests which "know" that
foo()
delegate tofnForString()
andfnForNumber()
e.g. by mocking them out and checking that it delegates to them?
Suppose you have function which takes a union type and then narrows the type and delegate to one of two other pure functions.
function foo(arg: string|number) {
if (typeof arg === 'string') {
return fnForString(arg)
} else {
return fnForNumber(arg)
}
}
Assume that fnForString()
and fnForNumber()
are also pure functions, and they have already themselves been tested.
How should one go about testing foo()
?
- Should you treat the fact that it delegates to
fnForString()
andfnForNumber()
as an implementation detail, and essentially duplicate the tests for each of them when writing the tests forfoo()
? Is this repetition acceptable? - Should you write tests which "know" that
foo()
delegate tofnForString()
andfnForNumber()
e.g. by mocking them out and checking that it delegates to them?
- 2 You created your own overloaded function with hard coded dependencies, Another way to achieve this kind of polymorphism (ad-hoc polymorphism to be precisely) is to pass the function dependencies as an argument (a type directory). Then you could use mock functions for testing purposes. – user5536315 Commented Oct 11, 2019 at 10:37
- Ok, but that's more a case of "how to achieve mocking" - so yes, you could pass the functions in, or have a curried function, etc. But my question was more on the level of "how to mock?" but rather "is mocking the right approach in the context of pure functions?". – samfrances Commented Oct 11, 2019 at 15:50
5 Answers
Reset to default 5The best solution would be just testing for foo
.
fnForString
and fnForNumber
are an implementation detail that you may change in the future without necessarily changing the behaviour of foo
.
If that happens your tests may break with no reason, this kind of problem makes your test too expansive and useless.
Your interface just needs foo
, just test for it.
If you have to test for fnForString
and fnForNumber
keep this kind of test apart from your public interface tests.
This is my interpretation of the following principle stated by Kent Beck
Programmer tests should be sensitive to behaviour changes and insensitive to structure changes. If the program’s behavior is stable from an observer’s perspective, no tests should change.
Short answer: the specification of a function determines the manner in which it should be tested.
Long answer:
Testing = using a set of test cases (hopefully representative of all cases that may be encountered) to verify that an implementation meets its specification.
In the example foo is stated without specification, so one should go about testing foo by doing nothing at all (or at most some silly tests to verify the implicit requirement that "foo terminates in one way or another").
If the specification is something operational like "this function returns the result of applying args to either fnForString or fnForNumber according to the type of args" then mocking the delegates (option 2) is the way to go. No matter what happens to fnForString/Number, foo remains in accordance with its specification.
If the specification does not depend on fnForType in such a manner then re-using the tests for fnFortype (option 1) is the way to go (assuming those tests are good).
Note that operational specifications remove much of the usual freedom to replace one implementation by another (one that is more elegant/readable/efficient/etc). They should only be used after careful consideration.
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 piler 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 anda&b
is an intersection type,a≡b
is an equality type. - A value
x
of an equality typea≡b
is a proof of the equality ofa
andb
. - If two values,
a
andb
, are not equal then it's impossible to construct a value of typea≡b
. - The value
refl
, short for reflexivity, has the typea≡a
. It's the trivial proof of a value being equal to itself. - We used
refl
in the proof ofnegateNegateIdempotent
andreverseReverseIdempotent
. This is possible because the propositions are trivial enough for the piler to prove automatically. - We use the
negateNegateIdempotent
andreverseReverseIdempotent
lemmas to provetransformTransformIdempotent
. This is an example of a non-trivial proof.
The advantage of writing proofs is that the piler verifies the proof. If the proof is incorrect, then the program fails to type check and the piler 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 piler 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()
andfnForNumber()
as an implementation detail, and essentially duplicate the tests for each of them when writing the tests forfoo()
? 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 pleteness I would remend including all the tests even if it introduces redundancy.
Should you write tests which "know" that
foo()
delegate tofnForString()
andfnForNumber()
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.
Assume that fnForString() and fnForNumber() are also pure functions, and they have already themselves been tested.
Well since implementation details are delegated to fnForString()
and fnForNumber()
for string
and number
respectively, testing it boils down to merely make sure that foo
calls the right function. So yes, I would mock them and ensure that they are called accordingly.
foo("a string")
fnForNumberMock.hasNotBeenCalled()
fnForStringMock.hasBeenCalled()
Since fnForString()
and fnForNumber()
have been tested individually, you know that when you call foo()
, it calls the right function and you know the function does what it is supposed to do.
foo should return something. You could return something from your mocks, each a different thing and ensure that foo returns correctly (for example, if you forgot a return
in your foo function).
And all things have been covered.
I think it's useless to test the type of your function, the system can do this alone and allow you to give the same name to each of the types of objects that interest you
sample code
// fnForStringorNumber String Wrapper
String.prototype.fnForStringorNumber = function() {
return this.repeat(3)
}
// fnForStringorNumber Number Wrapper
Number.prototype.fnForStringorNumber = function() {
return this *3
}
function foo( arg ) {
return arg.fnForStringorNumber(4321)
}
console.log ( foo(1234) ) // 3702
console.log ( foo('abcd_') ) // abcd_abcd_abcd_
// or simply:
console.log ( (12).fnForStringorNumber() ) // 36
console.log ( 'xyz_'.fnForStringorNumber() ) // xyz_xyz_xyz_
I'm probably not a great theorist on coding techniques, but I did a lot of code maintenance. I think that one can really judge the effectiveness of a way of coding only on concrete cases, the speculation can not have value of proof.