logo
Published on

A More Effective Method of Coding

featured Image
Authors

If you've spent any time working in software development, you've probably heard the term "test-driven development," or "TDD" for short.

TDD is based on the premise that you should create tests before you develop an implementation. Assume you wish to develop a method named calculateUserScore in a video game based on a user's K/D. TDD recommends that you begin by developing unit or integration tests to validate the input to an expected set of outputs.

Beginning with tests may be a huge help in ensuring that your software operates as intended after everything is said and done. However, even when you follow excellent testing methods such as hardcoding values and avoiding complicated logic, tests are still a type of coding. It's still software development, and at the end of the day, your tests must pass.

With the unknowns of implementation detail, it might be difficult to ensure that tests pass. After all, if you anticipate parseInt to behave one way and it acts another, you'll almost certainly have to redo any tests that relied on that assumption.

As a result, many people prefer to begin developing a function as a proof-of-concept, then slowly add tests alongside implementation: TDD-lite, if you will.

The problem is that you lose one of the most important benefits of test-driven development by doing so: the ability to compel you to confront your API ahead of time.

APIs are difficult to implement

You work for an independent game studio. You've created a little top-down shooter in JavaScript using Phaser. Your bassist has requested that you implement a user score.

"No issue, calculateUserScore will be quite straightforward - no need to overthink it."

You consider, as you type out a rudimentary implementation:

function calculateUserScore({kills, deaths}) {
    return parseInt(kills / deaths, 10)
}

But hold on! What about aides? Do those qualify? They certainly should. Let's consider them half of a kill.

function calculateUserScore({kills, deaths, assists}) {
    const totalKills = kills + (assists / 2);
    return parseInt(totalKills / deaths, 10)
}

Oh, but certain kills should be worth more points. After all, who doesn't like a nice 360-degree no-scope? While kills was previously just a number, let's convert it to an array of objects like follows:

const killsArr = [
     {
          additionalPoints: 3
     }
]

We can now swap out the function implementation for this:

function calculateUserScore({killsArr, deaths, assists}) {
    const kills = killsArr.length;
    const additionalPoints = killsArr.reduce((prev, k) => k.additionalPoints, 0);
    const totalKills = kills + (assists / 2);
    return parseInt((totalKills / deaths) + additionalPoints, 10);
}

While we've seen the function change, keep in mind that your game may be doing this computation in various places across the codebase. Furthermore, your API may still be unsuitable for this function. What if you want to show off spectacular kills with bonus points after a match?

Because of these significant refactors, each iteration necessitates extra restructuring work, potentially prolonging the time to ticket completion. This may have an influence on release dates or other planned launches.

Let's take a breather. What caused this to occur?

These issues are frequently caused by a misunderstanding of the scope of the project. This misunderstanding may occur between teams, between individuals, or even inside your internal monologue.

Testing is difficult

Many people recommend using TDD to deal around this difficulty. By incorporating a feedback loop, TDD can assist compel you to address your API ahead of time.

For example, before introducing the calculateUserScore function into your codebase, you may test against the initial version, add a test.todo to bring in assists, and discover you need to change your API before proceeding.

TDD, on the other hand, pushes you to address your API but does not assist you in determining scope. This lack of comprehension of your scope may have an influence on your API.

Allow me to explain:

Assume that the ability to monitor extraordinary kills after the fact will not be available until later in the development cycle. You are aware of this and have elected to halt at the second implementation, where kills remain a number. However, because the method is utilised often across the software, a broader refactoring will be required at a later point.

You may have discovered that advancements in the match-end screen were completed sooner than planned if you had communicated with other engineers. Unfortunately, it's only discovered in code review after you've completed the implementation, necessitating an instant rework.

Let's get to the point

Okay, I'll get to the point: There is a better approach to solve the "API shift" problem than TDD. "Documentation driven development" is the "superior method."

Writing documentation first can assist you in ironing out implementation details ahead of time before making difficult decisions about executing a design. Even reference APIs can assist you in creating a variety of designs.

Let's return to the previous example of calculateUserScore. You convened a brief meeting, like you had done previously, to gather the team's requirements. This time, though, instead of starting with the code, you begin by producing documentation.

You offer a description of how the API should appear based on these specifications:

/**
 * This function should calculate the user score based on the K/D of the
 * player.
 *
 * Assists should count as half of a kill
 *
 * TODO: Add specialty kills with bonus points
 */
function calculateUserScore(props: {kills: number, deaths: number, assists: number}): number;

You also select to highlight some examples in your documentation:

caluculateUserScore({kills: 12, deaths: 9, assists: 3});

You decide to hastily sketch out what the future API would look like when bonus points are added while reading these documents.

/*
 * TODO: In the future, it might look something like this to accommodate
 * bonus points
 */
calculateUserScore({kills: [{killedUser: 'user1', bonusPoints: 1}], deaths: 0, assists: 0});

You realise after writing this that you should use an array for the kills property initially, rather than afterwards. You don't need extra points; instead, you may just monitor an unknown user after each kill and alter it later.

calculateUserScore({kills: [{killedUser: 'unknown'}], deaths: 0, assists: 0});

While this may appear evident to us now, it may not be so in the future. The advantage of Documentation-Driven Development is that it requires you to go through a self-feedback cycle on your APIs and the scope of your work.

Process improvement

OK, I get what you mean. Documentation is regarded as a burden. While I could go on about how "your drug is healthy for you," I have good news: documentation does not imply what you believe it means.

Design mockups, API reference documents, well-formed tickets, future plan writeups, and other types of documentation can be discovered.

Documentation is essentially anything that may be utilised to share your opinions about a subject. Indeed, this includes exams. Tests are an excellent technique to deliver API samples for your use. TDD may be sufficient on its own to express that knowledge to future you, or it may be a suitable partner with other types of documentation.

If you're competent at creating largely integration tests, you're essentially writing usage API documentation while writing testing code.

This is especially true when developing developer tools or libraries. Seeing a usage example of how to accomplish something is tremendously useful, especially when paired with a test to confirm its functionality.

Another thing that "documentation-driven development" does not advocate is "write once and do it." This is a fallacy that may be detrimental to your scope and budget - time or otherwise.

As we shown with the calculateUserScore example, you may need to tweak your ideas before proceeding with the final release: this is OK. Docs impact code, and code influences docs. The same may be said with TDD.

DDD isn't simply good for writing production-ready code. In interviews, an effective tip for communicating your development methodology is to provide code comments first, followed by the solution. This allows you to make mistakes during the documentation phase (by writing comments) that will cost you less time than if you made a mistake during the implementation phase.

By doing so, you may demonstrate to your interviewer that you understand how to operate in a team and set well-defined goals. With these understandings, you will be able to work towards an edgecase-free* implementation.

Bring it back, y'all.

This essay already has more twists than an M. Night Shyamalan film, but here's another: documentation driven development, as we've discussed today, is a well-established notion. It's simply known by several names:

  • Development Based on Behaviour (BDD)
  • Test-Driven Development for Acceptance (ATDD)

Each of these relates to a method of validating the functioning of code that is responsible for user behaviour. Each promotes a more effective communication technique, which frequently includes documentation in the process. This style of logic is also known as "DDD."