Friday 23 October 2009 19:57
The debate between static and dynamic typing has gone on for years. Static typing is frequently promoted as effectively providing free tests. For instance, consider the following Python snippet:
def reverse_string(str): ... reverse_string("Palindrome is not a palindrome") # Works fine reverse_string(42) # 42 is not a string reverse_string() # Missing an argument
In a static language, those last two calls would probably be flagged up as compilation errors, while in a dynamic language you'd have to wait until runtime to find the errors. So, has static typing caught errors we would have missed?
Not quite. We said we'd encounter these errors at runtime -- which includes tests. If we have good test coverage, then unit tests will pick up these sorts of trivial examples almost as quickly as the compiler. Many people claim that this means you need to invest more time in unit testing with dynamic languages. I've yet to find this to be true. Whether in a dynamic language or a static language, I end up writing very similar tests, and they rarely, if ever, have anything to do with types. If I've got the typing wrong in my implementation, then the unit tests just won't pass.
Far more interesting is what happens at the integration test level. Even with fantastic unit test coverage, you can't have tested every part of the system. When you wrote that unit, you made some assumptions about what types other functions and classes took as arguments and returned. You even made some assumptions about what the methods are called. If these assumptions were wrong (or, perhaps more likely, change) in a static language, then the compiler will tell you. Quite often in a static language, if you want to change an interface, you can just make the change and fix the compiler errors -- “following the red”.
In a dynamic language, it's not that straightforward. Your unit tests as well as your code will now be out-of-date with respect to its dependencies. The interfaces you've mocked in the unit tests won't necessarily fail because, in a dynamic language, they don't have to be closely tied to the real interfaces you'll be using.
Time for another example. Sticking with Python, let's say we have a web application that has a notion of tags. In our system, we save tags into the database, and can fetch them back out by name. So, we define a
class TagRepository(object): def fetch_by_name(self, name): ... return tag
Then, in a unit test for something that uses the tag repository, we mock the class and the method
fetch_by_name. (This example uses my Funk mocking framework. See what a subtle plug that was?)
... tag_repository = context.mock() expects(tag_repository).fetch_by_name('python').returns(python_tag) ...
So, our unit test passes, and our application works just fine. But what happens when we change the tag repository? Let's say we want to rename
get_by_name. Our unit tests for
TagRepository are updated accordingly, while the tests that mock the tag repository are now out-of-date -- but our unit tests will still pass.
One solution is to use our mocking framework differently -- for instance, you could have passed
TagFetcher to Funk, so it will only allows methods defined on
TagFetcher to be mocked:
... tag_repository = context.mock(TagFetcher) # The following will raise an exception if TagFetcher.fetch_by_name is not a method expects(tag_repository).fetch_by_name('python').returns(python_tag) ...
Of course, this isn't always possible, for instance if you're dynamically generating/defining classes or their methods.
The real answer is that we're missing integration tests -- if all our tests pass after renaming
TagRepository's methods, but not changing its dependents, then nowhere are we testing the integration between
TagRepository and its dependents. We've left a part of our system completely untested. Just like with unit tests, the difference in integration tests between dynamic and static languages is minimal. In both, you want to be testing functionality. If you've got the typing wrong, then this will fall out of the integration tests since the functionality won't work.
So, does that mean the discussed advantages of static typing don't exist? Sort of. While compilation and unit tests are usually quick enough to be run very frequently, integration tests tend to take longer, since they often involve using the database or file IO. With static typing, you might be able to find errors more quickly.
However, if you've got the level of unit and integration testing you should have in any project, whether its written in a static or dynamic language, then I don't think using a static language will mean you have fewer errors or fewer tests. With the same level high level of testing, a project using a dynamic language is just as robust as one written in a static language. Since integration tests are often more difficult to write than unit tests, they are often missing. Yet relying on static typing is not the answer -- static typing says nothing about how you're using values, just their type, and as such make weak assertions about the functionality of your code. Static typing is no substitute for good tests.