Lesson Monday

Now that Jest is set up, it's time to test some code. We're now ready to write the business logic to check if three lengths make a triangle (and what kind of triangle they make) in our shape tracker application. Note that we should always commit our code after each passing test.

Over the last few sections, we've been adding pseudo-coded tests to our READMEs. We've done so with specific goals in mind: to learn how to use a TDD approach as a problem-solving tool and also to prepare for writing tests using Jest. Now that we're actually writing tests with Jest, it's no longer necessary to add pseudo-coded tests in your READMEs. Instead, we'll shift our focus to comprehensively testing our code with Jest.

Test 1: Should Correctly Create a Triangle Object with Three Lengths

Let's write our first test. A lot of the content will look familiar because we've written pseudocoded tests that look very similar to Jest tests.

__tests__/triangle.test.js
import Triangle from './../src/triangle.js';

describe('Triangle', () => {

  test('should correctly create a triangle object with three lengths', () => {
    const triangle = new Triangle(2,4,5);
    expect(triangle.side1).toEqual(2);
    expect(triangle.side2).toEqual(4);
    expect(triangle.side3).toEqual(5);
  });
});

The test above simply checks to see if the constructor works and properly instantiates a Triangle object with three properties: side1, side2, and side3. Remember that we should always start by testing the smallest possible behavior. In this case, it makes sense to check if we can properly instantiate a Triangle object.

Let's go over the different parts of this test including the new syntax.

  • First, we must always import any necessary code from other files. In this case, we need to import Triangle from './../src/triangle.js';. That way, Jest can actually access the file that needs to be tested. And don't forget that we used Babel to make sure Jest understands import and export statements - this is exactly the issue that Babel solves for us.

  • We've been using describe to group our pseudocoded tests so far so this term should be very familiar now. However, in the context of automated testing, we're going to be more specific about what it means.

We use describe() to define a suite. A suite allows us to group and organize tests. Note that we don't need to describe this suite as a Triangle. We could describe it as 'the Triangle and all its prototypes'. The description is there to make our code more readable. Our suite should be described in a concise and descriptive way. Since it's a suite that describes Triangle and its prototypes, describing it as a Triangle makes sense. If we were to test other shapes such as squares and circles, they'd have their own describe block. In the past, we've used the term to group together tests related to a specific function. That is fine too, especially if a function has a lot of tests.

  • We also have some new JS syntax: () =>. When we use this syntax, it's similar to saying function(). There are some other benefits to using what is called arrow notation. We will cover arrow notation further in a future lesson. You can use either () => or function() in your Jest tests. Keep in mind that the Jest documentation uses () =>.

  • Within our describe() block, we have a spec, or test. These terms should be very familiar at this point! Jest specs begins with test() - very similar to the pseudocode we've been using. Once again, we describe the content of the test, which generally begins with the word "should." Jest doesn't care what we put in this string. It's there so we can better communicate our intentions as developers. As always, we should try to be specific without being overly verbose.

  • Within the test, we can run any JavaScript code we need. Because this spec is testing whether we can properly create an instance of a Triangle, we just need a line of code for instantiating a triangle:

const triangle = new Triangle(2,4,5);

In general, we should minimize the amount of JavaScript code we put in a test. The goal is to focus on testing our source code. The more code we add to a test, the more likelihood that we'll have errors in the tests themselves - or that we'll end up not properly testing our source code at all.

  • Finally, every test should have an expectation. This is what we expect our result to be. This should be really familiar by this point, too! We denote this with Jest's expect() method. This method is common across test frameworks. It's also common to call an expectation an assertion. Once again, these terms can be used interchangeably. The syntax of the most common expectation we'll be using looks pretty much exactly like the pseudocode syntax we've been using up to this point:
expect(value1).toEqual(value2);

Note the toEqual() method. This is called a matcher. A matcher determines how value1 should match value2. If they are equal (the test returns true), the test will pass. If they aren't equal (and the test returns false), the test will fail.

In our pseudocoded tests, we only used toEqual(). However, there are many other matchers we can use as well. For instance, we can check if a value is less than another value, if it's true or false, and so on. For more information on matchers, see the Jest documentation on using matchers.

Generally, a single test will have one expectation. In fact, this is a best practice. However, the test above has three expectations. That's because it's more accurate to say that each spec should only test one thing. In this case, all of the expectations are testing the same thing: that a Triangle constructor has the properties it needs.

We've already written our Triangle constructor. However, let's go back to square one and delete the code in triangle.js. There's not much there, anyway, and in the process, we can make sure we use good testing practices. We'll start by demonstrating the difference between a good and a bad fail.

A Bad Fail

Now that we've deleted the code in triangle.js, let's run $ npm test.

Test is failing and shows an error.

Jest provides nice color coding for us. Green means the test passed while red indicates a fail. We've got a pretty obvious fail here, but it's not a meaningful one. Remember how we said there are good and bad fails? Well, in this case, the test throws a TypeError: _triangle.Triangle is not a constructor error when we try to instantiate a triangle.

Why is it throwing the error? Well, we don't have a constructor now. Our goal is to test the constructor itself, not the absence of a constructor. If a test can't find a method, file, or constructor and fails as a result, that is always going to be a bad fail. It's not really testing anything - it's just demonstrating that our code isn't wired up properly.

Another way to tell this is a bad fail is because our expectation is never reached. The error happens immediately so our test doesn't run successfully.

A Good Fail

Let's update our code to add our constructor back. We won't add any properties yet.

src/triangle.js
export default function Triangle(side1, side2, side3) {

}

We now have a constructor that's successfully exported, even if no properties are initialized when the constructor makes a new instance of a Triangle object. If we run $ npm test again, our test will fail - as expected - but in a different way:

We get a different failure message this time.

Now we have a new failure message:

expect(received).toEqual(expected) // deep equality

Expected: 2
Received: undefined

The key difference here is that we've actually reached our expectations. This way, we know that our test is properly connected to our code - and that we have a basic constructor in place. We expect our test to fail because our constructor doesn't have any properties yet.

It's important to get our tests to fail first because otherwise we might get a false positive. This could happen if we wrote the test incorrectly or if our specs are testing something other than we intended.

This is the red part of the RGR workflow - and it's now complete, at least for our first test. That means we are ready to work onto getting the test passing - making it green.

Passing Our First Test

Well, this part is simple. We just need to add back our constructor's properties:

src/triangle.js
export default function Triangle(side1, side2, side3) {
  this.side1 = side1;
  this.side2 = side2;
  this.side3 = side3;
}

Once we do that, we can run $ npm test again.

Jest shows one passing test.

As we can see, our test is color coded green now. We've passed our first test - and we can see why this is called the red-green-refactor workflow.

There are no opportunities to refactor - at least not yet - but dont' forget that we should always look for opportunities to improve our code after each passing test.

There's one other important thing to note before we move on. Only one test is listed as passing even though we have three expectations. This is part of the reason it's a good idea to have one expectation for one test. It's not clear from this passing test that multiple expectations were met. If each of those expectations were testing different things, our test coverage would appear spotty even if it's not. Once again, it's okay that we have multiple expectations here because they are just checking that the properties of a Triangle object are actually populated. However, in general we should stick with a very important principle: one expectation, one test.

Now that we have a passing test, it's important to commit your code. Remember, you should always commit your code after each passing test. Think of it as being like a save point where all is well and everything is working correctly - you can always return to this save point later if your code goes south. In addition, in the real world, we'll always want to commit after each passing test anyway to document our work. Just to clarify, you should commit not only the updated source code but also the updated tests.

Test 2: Should Correctly Determine Whether Three Lengths Are Not a Triangle

What is the next simplest behavior we can test? Well, let's consider all of the things our application should be able to do:

  • Check if three sides make a triangle or not
  • Check if a triangle is scalene
  • Check if a triangle is isosceles
  • Check if a triangle is equilateral

In our case, the simplest behavior is the first one. Our method should return false if the three lengths provided can't make a triangle.

Once again, we need to start by writing a test. We shouldn't be modifying our source code yet. It may be tempting to just write out the method - especially since the code we will be writing is relatively simple. However, it's very important that we practice this workflow now so we will be better prepared when the going gets tough.

Here's our next test:

...
  test('should correctly determine whether three lengths are not a triangle', () => {
    const notTriangle = new Triangle(3,9,22);
    expect(notTriangle.checkType()).toEqual("not a triangle");
  });
...

We describe the test as concisely as possible: 'should correctly determine whether three lengths are not a triangle'. Then we instantiate a Triangle object with lengths that won't make a triangle because 22 > (3 + 9) and the length of one side cannot be greater than the sum of the length of the other two sides.

Note that we make our variable name descriptive, too, and that we call a new method called checkType(). When testing, it's common to write code as if the method already exists - even if it doesn't. That can help us visualize how the method should be used. As always, methods should be clearly named, too.

Let's run $ npm test.

The outcome of our test.

There's a fail as expected. Here's the error:

TypeError: notTriangle.checkType is not a function

Is this a good or a bad fail?

If you guessed it's a bad fail, you are correct. Once again, we aren't actually testing our new method. At the very least, we need to create the method itself. Let's do that now:

src/triangle.js
 Triangle.prototype.checkType = function() {
   // Code will go here
};

If we run the test, we'll get a meaningful fail:

Now we have a meaningful fail again because we've added a checkType() function.

This is a meaningful fail because the method is actually called. It returns exactly what we'd expect: undefined. Now that we know our test is correctly calling our method, we can be sure that any changes we make to the method will also change the result of the test. If our test wasn't correctly calling this method, no amount of changes to checkType() would ever make our test pass. This is another big benefit of getting a good fail first. Sometimes we'll see students write code that should get a test passing - but because it's never wired up correctly, the test continues to fail, leading to extra development times and wrong turns as students unnecessarily go back to the drawing board.

Now let's add the code to make our new test pass:

triangle.js
 ...
 Triangle.prototype.checkType = function() {
  return "not a triangle";
};

We want to add as little code as possible to make our test pass. Right now, we don't care about conditionals or anything else just yet. Our method just needs to return "not a triangle" and our test will pass. This may seem like too small of a change, but an incremental approach can be especially useful when trying to solve difficult problems.

Since we have another test passing, it's time to make a commit. Once again, always commit after you get a test green. And make sure you commit both the test and the updated source code.

Test 3: Determine Whether Three Lengths Make a Scalene Triangle

We're ready for our third test. Let's check whether or not a triangle is scalene. This means that all three sides must be different. Here's the test:

triangle.test.js
  test('should correctly determine whether three lengths make a scalene triangle', () => {
    const scalTriangle = new Triangle(4,5,7)
    expect(scalTriangle.checkType()).toEqual("scalene triangle");
  });

The pattern should now be clear and this test is very similar to our last test.

We can make this test pass by adding a conditional that checks if all three sides are different:

triangle.js
Triangle.prototype.checkType = function() {
  if ((this.side1 !== this.side2) && ((this.side1 !== this.side3)) && ((this.side2 !== this.side3))) {
    return "scalene triangle";
  } else {
    return "not a triangle";
  }
};

The code above checks to see if all three sides are different. If they are, our method will return "scalene triangle".

However, if we run our tests again, we'll get a failure:

Our new test passes but our previous test fails.

It's important to read the output carefully. If we take a closer look, we'll see that our new test is passing. Our method correctly checks if a triangle is scalene. However, our new code broke our previous test because an object can have three different length properties and not be a triangle.

This is part of the reason it is so important to write thorough tests. Sometimes the new code we write will break previous tests. If our tests are well-written, then it probably means there's something wrong with our code.

This is also why it's important to keep all our tests even if we have moved on to new code. As developers, we can make sure that changes don't break our code by running our test suite. In fact, this is a central part of a concept called continuous integration. Continuous integration is the process of automatically testing our code whenever a change is made. As long as we have a robust test suite, we can be reasonably confident that we aren't introducing bugs in our code. We just need to run our tests and make sure they are all passing.

We'll need to refactor our code to get our tests to pass. Specifically, we'll need to add a conditional that actually checks whether the value of one side is greater than the sum of the other two sides.

triangle.js
...
Triangle.prototype.checkType = function() {
  if ((this.side1 > (this.side2 + this.side3)) || (this.side2 > (this.side1 + this.side3)) || (this.side3 > (this.side1 + this.side2))) {
    return "not a triangle";
  } else if ((this.side1 !== this.side2) && ((this.side1 !== this.side3)) && ((this.side2 !== this.side3))) {
    return "scalene triangle";
  }
};

Now our tests will pass again. Time to commit the tests and code!

Test 4: Determine Whether Three Lengths Make an Isosceles Triangle

We're ready to test whether a triangle is isosceles. That means two lengths (but not all three) must be equal. See if you can figure out the test on your own first. It's very similar to the ones we've written so far.

Here's the test:

triangle.test.js
test('should correctly determine whether three lengths make an isosceles triangle', () => {
  const isocTriangle = new Triangle(5,5,7)
  expect(isocTriangle.checkType()).toEqual("isosceles triangle");
});

We just need to add another conditional to our method to make the test pass:

triangle.js
Triangle.prototype.checkType = function() {
  if ((this.side1 > (this.side2 + this.side3)) || (this.side2 > (this.side1 + this.side3)) || (this.side3 > (this.side1 + this.side2))) {
    return "not a triangle";
  } else if ((this.side1 !== this.side2) && ((this.side1 !== this.side3)) && ((this.side2 !== this.side3))) {
    return "scalene triangle";
  }  else if ((this.side1 === this.side2) || ((this.side1 === this.side3)) || ((this.side2 === this.side3))) {
      return "isosceles triangle";
    }
};

Even if it seems we aren't adding a ton of code, it's time to commit again. We can ensure that we have a great commit history that clearly shows our work.

Test 5: Determine Whether Three Lengths Make an Equilateral Triangle

This test is almost exactly the same as our prior tests but we still need to write it. It would be very easy to make a mistake in our conditionals that might result in an equilateral triangle being identified as something else. We'll start with the test:

triangle.test.js
...
  test('should correctly determine whether three lengths make an equilateral triangle', () => {
    const equiTriangle = new Triangle(5,5,5)
    expect(equiTriangle.checkType()).toEqual("equilateral triangle");
  });
...

If we run this test, we'll get a fail as expected. However, the fail gives us an interesting piece of information:

Expected: "equilateral triangle"
Received: "isosceles triangle"

It's often very useful to check our fail messages. It's not just about making sure it's a meaningful fail. We may learn other information in the process. In this case, we can see that the current conditional for an isosceles triangle applies for an equilateral triangle as well. By paying attention to this information, we can avoid some gotchas. We can't just add an else statement to the end of our conditional - if we do so, our method will mistakenly say an equilateral triangle is isosceles. In this case, our test isn't just going to help us test our code - it can actually help inform the process of writing the code itself.

There's several ways we can solve this problem. Here's one of them:

triangle.js
Triangle.prototype.checkType = function() {
  if ((this.side1 > (this.side2 + this.side3)) || (this.side2 > (this.side1 + this.side3)) || (this.side3 > (this.side1 + this.side2))) {
    return "not a triangle";
  } else if ((this.side1 !== this.side2) && ((this.side1 !== this.side3)) && ((this.side2 !== this.side3))) {
    return "scalene triangle";
  } else if ((this.side1 === this.side2) && (this.side1 === this.side3)) {
      return "equilateral triangle";
  } else {
    return "isosceles triangle";
  }
};

Since we know that our previous conditional for an isosceles triangle is also true for an equilateral triangle, we update that condition to apply to equilateral triangles instead. Then our else statement will apply for isosceles triangles.

If we run our tests again, everything is green and passing. And, as always, we should commit our work.

At this point, it may seem like we are done - with the testing at least. We will make further updates to the UI in a future lesson, but for now it won't work correctly because there is still a bug. That's fine because we want to stay focused on testing for now. (If you want to get the UI running, though, just remember that our updated method is designed to handle numbers, not strings.)

Our testing could still be more thorough. We should consider as many possible use cases for this method. Extreme use cases are called edge cases. There are a number of other things this method should probably handle - technically they aren't extreme enough to be edge cases, but they can still be common gotchas if we don't think things through. These should be familiar from Introduction to Programming:

  • What happens if words or arrays are passed into the constructor?
  • What happens if a number is passed into the constructor but it's in string format?
  • What happens if negative numbers are passed in?
  • Would it be better to pass in triangle lengths as arguments to the checkType() method or should they be passed into the constructor as we do here?

Good developers think through these problems, write tests for them, and then update their code to handle a wide range of use cases.

You will be expected to write thorough tests for the upcoming independent project. First write a test and make sure you have a meaningful fail. Then make a descriptive commit. Get the test passing and commit again. Repeat this process for all your tests until the project is complete.

The following repository link includes all code in the shape-tracker project up to this point. It also includes several configuration updates that we'll be making in the next few lessons to add testing coverage and debugging.


Example GitHub Repo for Shape Tracker

Make sure to use the second commit titled "full testing of Triangle with Jest" as your point of reference.

Lesson 28 of 48
Last updated February 4, 2021