I have heard discussions about why static typing is superior to testing (and vice versa) as the way to lower the number of bugs in systems, and I think such discussions are futile as they are comparing apples and oranges.
Consider the following program fragment (in Python with type hints).
def foo(i: int, j: int) -> bool: return i < j def bar(i: int, j: int) -> bool: return i == j
If we consider only the type signatures of these functions, then we have the following.
foo: (int, int) -> bool bar: (int, int) -> bool
Using only type signatures, we can flag the following invocations as faulty.
foo("1", 3) # first argument is not of int type bar(3, [1,3]) # second argument is not of int type
However, type signatures are not sufficient to flag the following invocations as faulty.
if foo(3, 3): print("val1 is equal to val2") if bar(3, 5): print("val1 is less than val2")
Now, we might think that these faults would not have occurred if the functions were named as
less_than. By renaming, observe that we are not relying on static typing to avoid the faults :)
Nevertheless, let’s go ahead and rename the functions.
def equals(i: int, j: int) -> bool: return i < j def less_than(i: int, j: int) -> bool: return i == j if equals(3, 3): print("val1 is equal to val2") if less_than(3, 5): print("val1 is less than val2")
Even now, the code fragment is faulty.
The issue is there are (at least) two kinds of types and we have only specified only one kind of type. For the other kind of type, we are relying on the association between function names and expected function behaviors.
To understand the issue, consider the description of
- Accepts two integer values i and j and returns a boolean value.
- Returns true if the inputs are equal; false otherwise.
The first part of the description constrains the values that are valid as input to and output from the function. This description is the value (domain) type of the function, which we specified using type hints (e.g., : int).
The second part of the description constrains the behavior of the function. This kind of description is the behavioral type of the function (one that can be realized in different ways). In the example above, we did not specify this kind of type information.
Static typing as available in most programming languages today are geared towards only specifying value types (and not behavioral types).
If you are still not convinced about behavioral types, then consider relying on just the function signature
(List[int]) -> List[int] and picking a function that sorts a given list of integers in ascending order.
What about testing?
Unlike static typing (as it is generally available today), testing is not about value types.
Testing is about specifying and checking behavioral types.
Specifically, a test case captures a specific behavior of a function (e.g.,
equals(3,3) should evaluate to true,
equals(3,5) should evaluate to false), a test suite captures the behavioral type (i.e., a collection of expected behaviors) of a function, and testing checks if an implementation of a function is behaviorally type correct (i.e., function exhibits the expected behaviors).
To draw parallels with static typing, test suites are similar to type hints and testing is similar to type checking.
While testing may seem better than static typing, it is not the case. Unlike static typing, tests are neither succinct nor exhaustive in specifying the possible behaviors of a function under test (in general). This is true even with enhanced testing techniques such as property-based testing. Further, using testing as a way to ensure value type correctness is prohibitive in general.
So, where does this leave us?
Until the day we have static type systems that allow us to easily and succinctly specify both value and behavioral types and use them to check for type correctness, we will need both static typing and testing to lower the number of bugs in systems.
In short, static typing and testing are complementary. And, for now, we need to be able to specify and reason with types and create and execute tests.