
Jezen Thomas
Co-founder and CTO of Supercede. Haskell, Yesod, Nix. Technical audits and due diligence.
jezenthomas.com
Co-founder and CTO of Supercede. Haskell, Yesod, Nix. Technical audits and due diligence.
jezenthomas.com> def division(x, y): > return x / y > > > If you pass in 0 for y, itâll raise an error. You have to know to test for > the value specifically. Test coverage wonât save you here. With 100% line > coverage or branch coverages, it wonât be found. Thatâs how 100% test > coverage is not enough. So, if test coverage is not enough, what can we do here? Well, at least a couple of things. ## Surfacing Failure Cases Automatically It wouldnât be sensible to try testing our division function against every possible input, because the set of possible inputs is effectively unbounded. Fortunately, we donât have to. We can instead use a property-based testing library like hedgehog to generate inputs to our division function and show us the minimal way to reproduce an error when it occurs. This example is in Haskell, and only accounts for integral numbers. Otherwise, itâs a near enough direct translation of the Python version. import Hedgehog import Hedgehog.Gen qualified as Gen import Hedgehog.Range qualified as Range division x y = x `div` y prop_division_identity = property $ do x <- forAll $ Gen.int Range.linearBounded y <- forAll $ Gen.int Range.linearBounded let q = division x y -- quotient r = x `mod` y -- remainder q * y + r === x Weâre relying on the integer division law to test our function. The integer division law says: > When you divide one whole number by another, you get a quotient and a remainder. If you multiply the quotient by the divisor, then add the > remainder, you get back the original number. This law holds for divisor â 0. So, the test generates a range of integers, applies the division function to them, and checks that the result always adheres to the integer division law. Running this in GHCi quickly surfaces the problem. âââ Main âââ â prop_division_identity failed at div.hs:15:13 after 1 test. shrink path: 1: âââ div.hs âââ 10 â prop_division_identity = property $ do 11 â x <- forAll $ Gen.int Range.linearBounded â â 0 12 â y <- forAll $ Gen.int Range.linearBounded â â 0 13 â let q = division x y -- quotient 14 â r = x `mod` y -- remainder 15 â q * y + r === x â ^^^^^^^^^^^^^^^ â â âââ Exception (ArithException) âââ â â divide by zero In this case, we didnât have to explicitly check what happens when the divisor is zero. Furthermore â and perhaps more importantly â writing this test has forced us to think about both the types of the inputs and the range of those inputs. I didnât want to have to specify the upper and lower bounds for the range of inputs, but not all numeric types have explicit bounds. That led me to choose to constrain my division function to only support integers. We can see that limitation directly in GHCi. Îť minBound :: Int -9223372036854775808 Îť minBound :: Float <interactive>:49:1: error: [GHC-39999] ⢠No instance for âBounded Floatâ arising from a use of âminBoundâ ⢠In the expression: minBound :: Float In an equation for âitâ: it = minBound :: Float This also guides us towards thinking about edge cases. If we have to consider division on floating point numbers, then the obvious challenge is going to be in specifying to what degree of precision we expect to see in our results. We would then have to change the assertion in the test so that it accepts a result to a specific acceptable degree of accuracy. Again, the test encourages us to think, which in turn leads to better design. Thatâs true whether youâre writing a small function like division, or a larger, integrated path through your system. This is why TDD is often thought of as Test Driven Design, rather than just Test Driven Development. ## Making Failure Impossible Now that weâve surfaced the error, how do we design our function to make this kind of failure impossible? One common approach is to express the failure case in the output. This typically means wrapping the output in a Maybe to represent that the result may not exist. division :: Int -> Int -> Maybe Int division x 0 = Nothing division x y = Just (x `div` y) This works, but it necessarily implies that the caller of this code will have to handle the possibility of failure. This change will then ripple through the rest of the codebase, which is noise that you probably donât need. Alternatively, you can use the smart constructor pattern to constrain the input and have the changes ripple back towards the boundary of your system, which is usually a better design choice. In the case of our division function, this would mean ensuring at compile time that the function will only accept a non-zero divisor. division :: Int -> NonZero Int -> Int division x (NonZero y) = x `div` y Finally, we could of course return zero in case of failure. Which of these options is the right one is debatable, but the point is that both the tests and the types have driven us to think about the design of our system.Model â is not some peripheral detail that you interact with only occasionally. Because itâs so central, you work with it constantly and so the mental model for manipulating that data structure with lenses becomes rudimentary, rather than a novel curiosity. Lenses are so worth it if you have this design and the ergonomics constraints it brings. But if you donât? I donât think itâs worth it. Lenses in Haskell read quite differently from more conventionally written Haskell code. So much so, that while being ordinary Haskell, you could almost consider them a domain specific language. Working with lenses and maintaining a flow state requires familiarity, which I donât think you can establish if you only flirt with lenses by lightly dusting it around the periphery of your project. At Supercede, we havenât gone all-in on lenses. Some engineers have made the choice to lightly dust lenses on the periphery. Those parts now cause visible lines in the skin of programmersâ faces any time that programmer is forced to read and understand what weâve written. So, I regret our lens adoption. In hindsight, it seems gratuitous. Perhaps we indulged in whether or not some code could be written in terms of lenses, while neglecting to consider whether or not that code should be written that way. ## BEM Naming Conventions Stifled Thoughtful Design Itâs tough, balancing Not Invented Here syndrome with first principles thinking. Itâs not economically sensible to reinvent every wheel, nor is it sustainable to surrender to the learned helplessness of best practice, which is itself a form of fallacious reasoning. The Block Element Modifier naming convention is one such best practice. I regret allowing it to creep in to our codebase because I think it allowed some developers to try to absolve themselves of the kind of thinking required to come up with meaningful names for things. Communicating intent through clear writing is one of the most important parts of the job. The use of BEM has also made us stop thinking about how the problem of CSS selector scoping could otherwise be solved. The BEM naming convention could certainly be the right choice in some tech stacks, but in our Yesod project we have better tools! We generate unique identifiers for components at runtime, with some components being comprised of smaller components. This of course isnât exclusive to Yesod â other ecosystems solve this problem differently. But the point is that this problem can be solved differently, and to ignore that for the sake of best practice is folly. ## We Abstracted Too Early, and It Cost Us Haskell is a high level programming language. Almost all of the code we write describes what we wish to achieve, and not specifically how the runtime should achieve it. So, premature optimisation is not really a problem weâve faced historically. At least, not in terms of algorithmic efficiency. Instead, the premature optimisation that we have at times fallen afoul of is abstracting too early, and abstracting the wrong things. The worst offences here typically look like functions which are highly configurable, but donât really do anything particularly interesting. For example, extracting a user interface component library, so that all the buttons in your UI look the same. But in some places, the button should be a different colour, so you make that a configuration option. And then in some places, the button should be a different size, so you make that a configuration option. And then in some places, the button⌠You get the idea. A function that requires a load of configuration in order for it to understand how itâs meant to behave is probably a code smell. Instead of one highly configurable function, we could have written a few different functions. Or, we could have written a few different CSS mixins and composed those together. ## Haskellâs Type System Isnât a Substitute for Good Tests Haskellâs type system is brilliant. The type system obviates the need to write so many of the more onerous tests that we would have had to write and maintain if we were writing our project in something like Ruby or Python. Tests that assert the shapes of data structures, or that functions are called with the right arguments â that kind of thing. But ensuring that a SQL query doesnât throw an exception at runtime? We still need tests for that. Or documenting and automatically verifying the expected behaviour of a path through the system? We still need tests for that. There are many more examples to list, but this list isnât meant to be exhaustive. Despite being able to leverage a tool as powerful as the [Glorious] Glasgow Haskell Compiler, Iâm still seeing a huge amount of value in using automated tests to drive out the design of the system. This shouldnât really be surprising either â types and tests are different things and serve different purposes. In parts of our project where the test suite is well written, Iâm seeing that it continues to be well written as my colleagues write more production code. This might come from a kind of social pressure â conscientious developers generally want to leave a project in a better state than they found it. If a test suite is well written, it just feels bad to make a change that leaves the test suite relatively less comprehensive. So, my regret here is in not investing enough in establishing a culture of test-driven design. And I donât just mean we havenât written enough tests. Thereâs an art to writing test code, since its purpose isnât purely to prevent regressions. If we had a better cultural awareness of how to use automated tests to drive out the design of our system, weâd almost certainly have a better production codebase for it. We do have this culture at least partially established in our team, but itâs harder to add this after the fact. And because the existing test code isnât always ideal, the path of least resistance isnât to write ideal test code, and the relative growth of good test code against new production code isnât self-perpetuating. In other words, it takes more effort now to get to where we want to be with our test code than if we had done this up front. ## Slow CI Kills Productivity Programmers have a kind of adversarial relationship with continuous integration systems. Itâs a healthy conflict though â the whole point is to ensure that the changes you make are sound. As you work, you push changes and your CI system runs an assortment of checks against them. You might be otherwise happy with your contribution and all of your automated tests are passing during local development, but then the CI system flags that you neglected to add a migration. Or that the code fails some linting rules, or that while your code works just fine on the compiler version or system architecture that you are running locally, it fails on other compiler versions or system architectures that you also need to support. So, the build on CI is failing. You make another change, push that change, and wait again for CI to pass or fail. Another failure. Make more changes. Push again. Wait. This style of working is of course fine, but if CI is slow, it means your iteration cycle is slow. And a slow iteration cycle easily distracts programmers and kills motivation. Scaling this lack of efficiency up to a team of people means the company isnât shipping software as fast as it should. In hindsight, I should have invested more effort into keeping tests fast and build times short. The compound productivity gains from this just make economic sense. ## Lessons Learned Looking back, the choices we made were [usually] generally well-motivated. If I were starting Supercede today however, I would take a firmer stand against over-engineering, and I would be more conservative â not just in the number of tools and libraries we depend on, but also in how much of any given language we use. Haskell is a great language, but we donât need to use all of it. This retrospective has also reaffirmed to me how important test-driven design is as a way to approach software development. I learned all about it a decade ago, forgot it, and then had to relearn â painfully â why TDD matters. Finally, I donât regret for a minute any of the time I spent learning about logical fallacies. Software engineering as an industry is rampant with fallacious reasoning, and often itâs enough to recognise fallacious reasoning when itâs being employed in order to not fall prey to it. --- 1. Or maybe there is a way to do this, and I just havenât figured out how yet.âŠď¸