When we start a new project - be it a newly formed startup, or a new project inside an existing company, we have to move fast, prove concepts, and decide if the idea is worth pursuing. we don't have the privilege to invest in infrastructure or scale, nor do we need to, since we don't know what scale issues we might face; when we finally meet interesting scales, our product might be completely different to the one we're writing now.
As Kent Beck mentions in Explore/Expand/Extract, the first phase of any project is exploratory and might be thrown away in favor of a different idea. Does that mean that we shouldn't invest in software engineering during the exploration stage? Can't we all just agree that we're writing a prototype and that as soon as we've proven product-market fit we'll take the time to rewrite everything from scratch?
There Are No Prototypes
I can see you smiling a sad smile. Yes you, who inherited a 7-year-old codebase with the footprints of 12 different engineers, none of whom managed (or cared) to make a dent in the accumulating pile of technical debt. Or you, who promised to yourself and to your team that as soon as you've completed your Series A funding, you're going to take a few weeks to tidy things up. So you didn't write tests, you never got around to refactor anything, you're stuck with a 3-year-old version of React because you're coupled to obsolete implementation details and you're afraid to make changes to production code because there's a lot of money on the table right now, but also there are a ton of new features that you must develop by last month because there's a lot of money on the table right now and....
The problem is, of course, that we keep lying to ourselves. We mean well, and we really do hope that this time is going to be different, but let's face it, folks: there's no such thing as a software prototype. This is your product you're now building. It's here to stay. It's permanent. If you're lucky enough to hire a relentless software engineer, you might some day be able to throw away the last remnant of the abomination you're now writing. More often than not, though, if you start with a rotten codebase it's only going to rot more over time. A permanent prototype.
Martin Fowler talks about Sacrificial Architecture, the idea of deliberately going with solutions that won't last more than a few years, but it differs from the permanent prototype problem in two major ways: first, we must make a deliberate decision to go with a Sacrificial Architecture, while in many cases the prototype is built on top of "forwards-looking" architectures that are premature and only create problems. Second, this is an idea best applied to highly-cohesive systems that can easily be broken into parts and have different parts be gradually-rewritten in different times.
Taking The Red Pill
If we, for a moment, decide to stop deluding ourselves and accept our reality as it is, shouldn't we make the tinniest little effort, from the initial days of our development, to invest in our ability to maintain our codebase? I say that we should, nay, we must. And that the best gift we can give ourselves is the time to write an end-to-end (E2E) test. "What?", you say, "are you insane? who's got the time to deal with tests right now?! I'm running on seed money collected from my parents, my best friend and a senile uncle". Let's deconstruct this argument and show that the marginal investment it takes to write an E2E test early on is quickly returned. But first, I'll explain what I mean by an E2E test and why it is (almost) the only type of test we should write in the early stages of development.
When people think about tests, they automatically imagine fine-grained, meticulous unit tests that mirror the production code. A class Foo will have a companion FooTest (or foo.spec.js) with a test function for each of its methods. While this has inarguably been the experience of many a software engineer, it doesn't mean it's a good practice. In fact, this is one of the major anti-patterns I find with teams who claim to be practicing TDD. An E2E test is the complete opposite; you have a single test (maybe more tests later on when additional user stories emerge), which deals more with the mechanics of how your software works than with any specific functionality.
Let's imagine we're writing a stock market app using React Native as our platform of choice. We'll write a Detox test that adds a stock to our portfolio and expects to see it in the portfolio. The test would look something like this:
await element(by.label('add-stock-to-portfolio')).tap(); await element(by.label('find-ticker')).typeText('SPY'); await element(by.label('search-result')).atIndex(0).tap(); await expect(element(by.label('stock') .withAncestor(by.label('portfolio')))).toHaveText('SPY');
Think for a moment about what it takes to make this test pass. You start by writing a UI component but then very quickly you have to make a decision about data: your ticker search need to have some data source; you can decide at this point to use a client-side fixture ("mock data") or you can extend the scope of your E2E to a backend or a Serverless architecture. Either choice is good: the former would make you commit to delaying any backend work until the app is more mature, saving unnecessary work on backend code, while the latter would force you to make some initial decisions about technological choices, and would probably lead you towards a simple solution as the path of least resistance. Either way, when this test passes you have defined the scope of development better and created a safety net against any major bugs preventing the app from completing its basic (and only) flow.
The Plot Thickens
"Oh!", you might yell, "but what if I want to pivot? I've wasted all of this expensive time on a test for a product I just threw away". But what exactly, in the test above, is specific to the stock market? you could just as easily be searching for a product and adding it to a shopping cart - the only thing that changes is the semantic meaning of the components, but the mechanics remain the same. Of course, if we decide to throw away React Native in favor of a shinier technology, our test (which is not a complete black box test due to the nature of Detox) becomes useless. The same goes if we decide to stop developing a mobile app. But in both cases, it's not only the test that becomes useless, it's the entire project. We start the next exploration from scratch, both in terms of product and in terms of technology. Which might not be such a bad idea, given our love as developers for throwing away old code and writing new stuff :)
In the happy case, where we do not decide to throw away the entire codebase, we're now ready to add new functionality. Which we can do by adding another case to our E2E, see it fail, and embark on a few TDD iterations to implement it. Eventually, the E2E will have become too cumbersome or slow, and we'll find ourselves writing finer-grained tests, perhaps component tests or unit tests, and refactoring the older code and test cases to fit the new design. Our codebase has started clean and tidy, and it will remain clean and tidy (and adaptive to changing requirements). We'll be able to make changes without fear, and we'll be able to release test versions of our app as part of our CI/CD workflow. Which will make us and our investors happy.
When starting a new project, it makes no sense to invest in solving scaling problems, since we have no idea what scale problems we'll face if and when our product gains a lot of usage. Having said that, we don't want to tie ourselves down unnecessarily - building on top of scalable infrastructure makes dealing with unexpected scale a billing issue rather than an engineering issue. In very much the same vein, there's no need, and it would probably be a big mistake, to invest in a lot of fine-grained tests early on, such as unit tests or component tests - these are used to assert behavior, which is one of the things that change often in exploration mode. However, a single, coarse-grained E2E test with little assertions will make sure that your mechanics are always in good order, and will help a good, adaptable, maintainable design emerge, by serving as the primary driver for adding new behavior via TDD when it is time to do so.