Lesson Tuesday

As our projects get bigger, we'll need to break up our code into multiple files. Doing this - and managing our tests as well as our import and export statements - can be tough for beginners. For that reason, we'll walk through the process by adding functionality for calculating the area of a rectangle to our shape-tracker application. In the process, we'll use ES6 classes and update our application so the UI can check if three lengths make a triangle and calculate the area of a rectangle. It's a hodgepodge of functionality, but that's not the point. Instead, the goal here is to keep our code modular and well-organized. The principles we apply here can be used for any number of business logic and test files.

We are adding simple functionality to our application - and it would be easy to just shove the new code into the files we already have. However, that would be a bad move. In the real world, we need to think about scalability. Specifically, how can we make our applications scale up and grow bigger with a minimum amount of pain points? While we should have a general road map for how an application might expand, we can't predict everything the application might need. If it's a successful application, it will likely look very different in five years than in does now. For that reason, we always need to build with an eye on the future. Think of the analogy of making a building. If it has a strong foundation, we can add more stories on it in the future. If it has a weak foundation, it will need major overhauls - or worse, we might need to start from scratch - in order for us to keep building. When an application with a weak foundation starts running into scalability problems, it can lead to major headaches for businesses - pain points, wasted developer time, less time spent on new features that users want right now - not a year from now. And if competitors are already building those features and other problems arise for users, they will quickly desert the application.

Modular code scales better and is easier to read. There are fewer issues with global scope and fewer bugs. Developers can work more efficiently on different parts of the codebase - and they'll be able to communicate better, too.

Project Structure

We already have most of the files we need. Because we're only adding a small amount of functionality, we'll just need two new files. We'll also add a new directory for our js code as well - because it's always better to organize our code in directories.

  • src/js/rectangle.js: This will contain the business logic for a Rectangle class.
  • __tests__/rectangle.test.js: This will contain the test suite for tests related to the Rectangle class.

Add those files to the project now. Also, don't forget to move triangle.js into the js directory. VSCode has a handy little feature where it can automatically update any import statements in the code for you. Here's an example of the prompt (though this one is for rectangle.js).

VSCode prompt offers to automatically update imports.

If you want to do it manually (or VSCode doesn't automatically update the import statements), the relative path for the triangle.js import statement in triangle.test.js looks like this:

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

We'll update import statements in the UI (main.js) later in this lesson.

By the way, main.js shouldn't be in your js directory. It should be in src because it's our entry point file.

Updating to ES6 Classes

Before we move on, let's update the code in triangle.js to use ES6 classes:

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

  checkType() {
    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";
    }
  }    
}

Because we've made a code update, we should verify that our tests still pass. And they do. Because of our tests, we can be assured that everything is still working correctly after refactoring our code.

Writing - and Passing - Our First Test

Because we are using a test-driven approach, our next step is to write a test. We'll start with a test for a Rectangle constructor:

src/rectangle.test.js
import Rectangle from '../src/js/rectangle.js';

describe('Rectangle', () => {

  test('should correctly create a rectangle object using two sides', () => {
    const rectangle = new Rectangle(3,5);
    expect(rectangle.side1).toEqual(3);
    expect(rectangle.side2).toEqual(5);
  });
});

Because a rectangle has two pairs of sides, each with equal length, we'll only need to pass in two sides as parameters.

As expected, this test will fail, but it should be clear by this point that it's a bad fail:

TypeError: _rectangle.default is not a constructor

It's clear why that's the case. There's no constructor yet! Let's add just enough code to have a good fail.

src/js/rectangle.js
export default class Rectangle {
  constructor() {
  }
}

We just add and export a Rectangle class with an empty constructor.

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

    Expected: 3
    Received: undefined

This is a better fail. We've reached our expectation and we know our code is properly wired up.

Next, let's get the code passing by adding parameters and statements to our constructor:

src/js/rectangle.js
export default class Rectangle {
  constructor(side1, side2) {
    this.side1 = side1;
    this.side2 = side2;
  }
}

Once we save, VSCode will automatically run the tests again - and everything is passing.

By the way, note that we use the same parameters as we do for triangles (side1 and side2). Imagine, for a moment, the havoc that would occur if these variables were globally scoped. It's very common to reuse variable and property names. Thankfully, we can scope them locally.

Next, we'll need to write a test for our only function:

__tests__/rectangle.test.js
import Rectangle from '../src/js/rectangle.js';

describe('Rectangle', () => {

  ...

  test('should correctly create a rectangle object using two sides', () => {
    const rectangle = new Rectangle(3,5);
    expect(rectangle.getArea()).toEqual(15);
  });
});

If we run our tests now, we'll get a bad fail:

TypeError: rectangle.getArea is not a function

Our new method doesn't exist yet - of course testing something that doesn't exist will result in a fail - and a bad one. Here's the code we need for a good fail:

src/js/rectangle.js

  getArea() {

  }
}

And here's the fail:

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

    Expected: 15
    Received: undefined

That's much better! Finally, let's add the code to get the test passing:

src/js/rectangle.js
...
  getArea() {
    return this.side1 * this.side2;
  }
...

Now all our tests are passing.

DRYing Up Our Tests

We should always look for an opportunity to refactor our code. Our source code looks fine but we can DRY up our tests a bit because we are using some repeated code: const rectangle = new Rectangle(3,5);. If we were to build out our code further and add more tests, it would be nice to have a reusable rectangle. This also gives us an opportunity to practice adding a beforeEach() block in our code. Here's the updated tests refactored to use a beforeEach() block:

__tests__/rectangle.test.js
import Rectangle from '../src/js/rectangle.js';

describe('Rectangle', () => {

  let rectangle;

  beforeEach(() => {
    rectangle = new Rectangle(3,5);
  });

  test('should correctly create a rectangle object using two sides', () => {
    expect(rectangle.side1).toEqual(3);
    expect(rectangle.side2).toEqual(5);
  });

  test('should correctly create a rectangle object using two sides', () => {
    expect(rectangle.getArea()).toEqual(15);
  });
});

Updating the UI

Now that we have all tests passing, we're ready to update our UI. As we mentioned earlier in the lesson, main.js is not in our js directory - it's in src because it's our entry point file.

src/main.js
import $ from 'jquery';
import 'bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import './css/styles.css';
import Triangle from './js/triangle.js';
import Rectangle from './js/rectangle.js';

$(document).ready(function() {
  $('#triangle-checker-form').submit(function(event) {
    event.preventDefault();
    const length1 = parseInt($('#length1').val());
    const length2 = parseInt($('#length2').val());
    const length3 = parseInt($('#length3').val());
    const triangle = new Triangle(length1, length2, length3);
    const response = triangle.checkType();
    $('#response').append(`<p>${response}</p>`);
  });

  $('#rectangle-area-form').submit(function(event) {
    event.preventDefault();
    const length1 = parseInt($('#rect-length1').val());
    const length2 = parseInt($('#rect-length2').val());
    const rectangle = new Rectangle(length1, length2);
    const response = rectangle.getArea();
    $('#response2').append(`<p> The area of the rectangle is ${response}.</p>`);
  });
});

There are a few key things to note:

  • We have import both Triangle and Rectangle. As our projects grow in size and our UI needs access to more business logic files, we'd add more import statements here.
  • We add parseInt() when we get the value of lengths from both forms. We don't want to have an issue with working with strings instead of numbers.
  • When we append, we've updated the strings to use template literals. This cleans up the code a bit more.

And that's really it! It's not a fancy UI but everything is wired together correctly. Most importantly, this lesson should provide a clearer picture of how we can have multiple business logic files working with our UI and tests.

Remember, whenever a file needs access to a function, class or some other code from another file, we just need to use import/export statements. We can use these in any JavaScript file. For instance, we might have a business logic file that imports a function from another business logic file. In that case, import and export statements are applicable in the exact same way.

As you build out a bigger project, take the time to break up your business logic into smaller, more modular files and then use import and export statements as needed. webpack will take care of the rest!

Below is a repository for the complete project.


Example GitHub Repo for Shape Tracker

Lesson 41 of 48
Last updated April 8, 2021