A Lifecycle Of Code Under Test

bob.ts - Jul 29 '19 - - Dev Community

This article was written in conjunction with Dan Wypiszynski (one of my co-workers).

Here's the original version of this article / talk ...

Whiteboard documentation of this Article / Talk

When examining of front-end code patterns these days, developers should also equate testing as a part of the development cycle.

This article is about the unit and integration tests that a developer writes to test their code immediately, not about higher level tests that are written by a QA individual or department. In this article, I will set aside the "chicken or the egg" discussion of Test Driven Development and/or Behavior Driven Development. For a higher level view, see ...

I want to focus on the code and tests more holistically at each lifecycle stage.

When thinking about these tests, I am always concerned that I have "done enough." How do I know that the code is covered appropriately, that odd bugs aren't going to crop up? Did I cover all the use cases and what if someone changes my code down-the-road?

There is a definite and clear lifecycle to testing ...

  1. Define Inputs and Outputs
  2. Write Initial Test Coverage
  3. Handling Bug Coverage
  4. Refactoring
  5. Abstraction
  6. Future Work (how are tests affected?)

The code used here is closer to pseudocode than actual JavaScript (this means that I did not run the code or tests to see if the work). The code is here to illustrate the points being discussed.

Base Code

To examine the code-test lifecycle, assume the following is some overly complicated chunk of code we've written or want to write (BDD or TDD).

This function overlyComplicated should take two strings (a and b), adds them together, then returns the first len characters of the resulting string.

The "complicated" and "injected" bug parts are intentional; allowing the lifecycle to have a path forward.

function overlyComplicated(a, b, len) {
  var sum = "";

  if (len < 1) {
    return "";
  }

  for (var i = 0; i < a.length; i++) {
    sum = sum + a[i];
  }
  for (var i = 0; i < b.length; i++) {
    sum = sum + b[i];
  }

  // "INJECTED" BUG HERE
  if (len === 2 || len === 4 || len === 6) {
    return "unexpected";
  }

  return sum.subsrt(0, len);
}

var oC = overlyComplicated;
Enter fullscreen mode Exit fullscreen mode

Define Inputs and Outputs

Looking at the code: function overlyComplicated(a, b, len) and return sum.substr(0, len), we can begin to define the inputs and outputs of this function.

Inputs

  • a: string of some length.
  • b: string of some length.
  • len: number (integer) of characters of the combined to return.

Outputs

  • string of "len" characters.

Examples

  • ("abc", "def", 0) returns ""
  • ("abc", "def", 1) returns "a"
  • ("abc", "def", 3) returns "abc"
  • ("abc", "def", 5) returns "abcde"

Write Initial Testing Coverage

All Branches (paths)

  • There are no branches in this code; each should be covered if they exist.

Positive Testing

Positive testing, often times referred to as Happy Path Testing is generally the first form of testing that a developer will perform on some code. It is the process of running typical scenarios against the code. Hence as implied, positive testing entails running a test scenario with only correct and valid data.

  • expect(oC("abc", "def", 1)).toEqual("a");
  • expect(oC("abc", "def", 3)).toEqual("abc");
  • expect(oC("abc", "def", 5)).toEqual("abcde");

Negative Testing

Negative testing commonly referred to as Error Path Testing or Failure Testing is generally done to ensure the stability of the code.

This is the process of applying creativity and validating the code against invalid data. This means its intended purpose is to check if the errors are being handled gracefully.

For this code, we will only examine the result of a non-positive value for the len input.

  • expect(oC("abc", "def", 0)).toEqual("");
  • expect(oC("abc", "def", -1)).toEqual("");

Handling Bug Coverage

Here, examining the "bug" code ...

// "INJECTED" BUG HERE
if (len === 2 || len === 4 || len === 6) {
  return "unexpected";
}
Enter fullscreen mode Exit fullscreen mode

Repeat The Bug In Test Form ...

expect(oC("abc", "def", 2)).toEqual("ab");

  • Returns ... expect "unexpected" to equal "ab".

expect(oC("abc", "def", 4)).toEqual("abcd");

  • Returns ... expect "unexpected" to equal "abcd".

expect(oC("abc", "def", 6)).toEqual("abcdef");

  • Returns ... expect "unexpected" to equal "abcdef".

Fixing The Bug

After removing the "bug" code ...

function overlyComplicated(a, b, len) {
  var sum = "";

  if (len < 1) {
    return "";
  }

  for (var i = 0; i < a.length; i++) {
    sum = sum + a[i];
  }
  for (var i = 0; i < b.length; i++) {
    sum = sum + b[i];
  }

  // "INJECTED" BUG HERE
  // if (len === 2 || len === 4 || len === 6) {
  //   return "unexpected";
  // }

  return sum.substr(0, len);
}

var oC = overlyComplicated;
Enter fullscreen mode Exit fullscreen mode

All three tests should now be passing ...

  • expect(oC("abc", "def", 2)).toEqual("ab");
  • expect(oC("abc", "def", 4)).toEqual("abcd");
  • expect(oC("abc", "def", 6)).toEqual("abcdef");

Refactoring

To show a simple refactor, let's comment out the "overly complicated" part of the code and build a much simpler form.

function overlyComplicated(a, b, len) {
  var sum = "";

  if (len < 1) {
    return "";
  }

  sum = a + b;
  sum = sum.substr(0, len);
  return sum;

  // for (var i = 0; i < a.length; i++) {
  //   sum = sum + a[i];
  // }
  // for (var i = 0; i < b.length; i++) {
  //   sum = sum + b[i];
  // }

  // return sum.substr(0, len);
}

var oC = overlyComplicated;
Enter fullscreen mode Exit fullscreen mode

Based on this code change, all previous tests should be expected to still pass. If there was no coverage, the developer needs to take every step to cover the code, as-is, unchanged.

Positive Tests

  • expect(oC("abc", "def", 1)).toEqual("a");
  • expect(oC("abc", "def", 3)).toEqual("abc");
  • expect(oC("abc", "def", 5)).toEqual("abcde");

Negative Tests

  • expect(oC("abc", "def", 0)).toEqual("");
  • expect(oC("abc", "def", -1)).toEqual("");

Bug Tests

  • expect(oC("abc", "def", 2)).toEqual("ab");
  • expect(oC("abc", "def", 4)).toEqual("abcd");
  • expect(oC("abc", "def", 6)).toEqual("abcdef");

Abstraction

Now, let's examine an abstraction within this code ...

function getSum(a, b) {
  return a + b;
}

function overlyComplicated(sumFn, a, b, len) {
  var sum = "";

  if (len < 1) {
    return "";
  }

  sum = sumFn(a, b).substr(0, len);
  // sum = a + b;
  // sum = sum.substr(0, len);
  return sum;
}

function oC(a, b, len) {
  return overlyComplicated(getSum, a, b, len);
}
Enter fullscreen mode Exit fullscreen mode

Given this code change, all previous tests should still pass. But, we can now add testing against the getSum function.

  • expect(getSum("abc", "dev")).toEqual("abcdef");

Certainly, more testing can be done: more positive testing, as well as negative testing. Additionally, overlyComplicated is now given a means to mock, replace, or spy on the getSum function, if needed.

Future Work (how are tests affected?)

Starting with the abstraction just applied, what if someone comes along in the future and adds a global object and injects a line of code into the overlyComplicated function to add the sum to the object.

var global = {};

function getSum(a, b) {
  return a + b;
}

function overlyComplicated(sumFn, a, b, len) {
  var sum = "";

  if (len < 1) {
    return "";
  }

  sum = sumFn(a, b).substr(0, len);
  global.sum = sum;
  return sum;
}

function oC(a, b, len) {
  return overlyComplicated(getSum, a, b, len);
}

Enter fullscreen mode Exit fullscreen mode

How Are Tests Affected?

  • Per Black-Box Testing, no test should fail (purely examining inputs to outputs).
  • Per White-Box Testing, tests should be written to cover the new code.

Future Work Tests

... given

  • oC("abc", "def", 1);
    ... then

  • expect(global.sum).toEqual("a");

Summary

The definite and clear lifecycle in testing encompasses ...

  1. Define Inputs and Outputs
  2. Write Initial Test Coverage
  3. Handling Bug Coverage
  4. Refactoring
  5. Abstraction
  6. Future Work (how are tests affected?)

A proper level of testing will ensure a developer ...

  1. Has "done enough."
  2. Knows the code is covered appropriately.
  3. Is secure in the knowledge that odd bugs don't continue to exist
  4. And that the code will survive future changes.

Testing, applied in a methodical manner, will allow a developer ...

  • To have CONFIDENCE in code being released without defects,
  • And PROOF that it works.
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .