Using TDD to develop a reliable getPrices function

How to use TDD to develop reliable features

In another thread, code is wanted to get price information from some select options. While that’s something that can be quickly developed, this provides a good opportunity to explore using test-driven development to develop the feature.

Set up the testing environment

We’ll use Mocha and Chai where Mocha provides the overall testing framework, and Chai is an assertion library letting us easily compare different things.

Normally Mocha&Chai are run on Node or some other server-based platform. In this case we won’t be using them and will instead be setting up Mocha&Chai in a standalone configuration, so that it can be easily added to an existing page.

We have some CSS and JS code to load for Mocha, and JS for Chai.

The CSS we’ll put in the head of our document. It’s not required but it helps to make things look better:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/6.2.1/mocha.min.css">

The JavaScript we’ll add to the end of the body.

<body>
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/6.2.1/mocha.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chai/4.2.0/chai.min.js"></script>
</body>

Mocha shows its results in a separate div, so we’ll add that to the top of the page:

<body>
<div id="mocha"></div>
...
</body>

And we tell mocha and chai to run with the following script:

mocha.setup("bdd");
const expect = chai.expect;
// put testing code here
mocha.run();

Does this work?

Our first test checks if the testing framework is figuratively plugged together properly.

it("runs a test", function () {
expect(true).to.equal(true);
});

We see a successful test at the top of the webpage.

✓ runs a test

We can now replace that initial test with a first test for the getPrices function.

Next steps

With TDD it’s usally better to start with a simple test and work your way up to more complex situations. The types of function inputs that we will use for our tests are:

  • no function parameter
  • select with no options
  • select with one option
  • select option with valid price
  • select option with multiple prices
  • select option with invalid price

The next posts will cover what takes place in these tests. Each one seems simple, and that’s the point. Even so, beneficial progress takes place with each and every one of them.

1 Like

Null parameter test

Testing what happens when nothing is given to the function has several benefits. Among others they are:

  • we decide how we’re going to make the overall code more testable
  • we gain access to the function that we’re testing
  • we decide what happens with unexpected (or no) input

The test is what we start with.

What are we testing?

Good tests have a 3-part structure to their names:

  • What are we testing → “get prices”
  • What’s done to it → “with no parameters”
  • What do we expect → “gives an empty array”
    describe("get prices", function () {
        it("has no parameters, gives an empty array", function () {
            ...
        });
    });

We will have several tests, so it’s best to group them together under the “get prices” title.

What are we going to test? It’s going to be a getPrices() function.

What should the function do when it has no inputs?
It’s normally going to give us an array of prices, so with no inputs it makes sense for us to get an empty array.

    describe("get prices", function () {
        it("has no parameters, gives an empty array", function () {
            expect(getPrices()).to.equal([]);
        });
    });

And the test appropriately fails saying: "ReferenceError: getPrices is not defined"

Need something to test

We don’t have a function yet. I want the function to be inside of an IIFE (immediately invoked function expression) with other code.

(function iife() {
    ...
    function getPrices() {

    }
    ...
}());

But how is the test to gain access to that getPrices function?

The best approach is to not have direct access, and to test eventual side-effects.
We don’t have that luxury right now, but it is a good direction to eventually aim in.

For now we can have the iife expose certain parts of it, by returning an object containing references to what we need to access.

// (function iife() {
const prices = (function iife() {
    ...
    function getPrices() {

    }
    ...
    return {
        getPrices
    };
}());

We can now access the getPrices function:

    describe("get prices", function () {
        it("has no parameters, gives an empty array", function () {
            // expect(getPrices()).to.equal([]);
            expect(prices.getPrices()).to.equal([]);
        });
    });

And we get a new test failure: "AssertionError: expected undefined to equal []"

Make the test pass

The simplest thing to do to make the test pass is to return an empty array from the function.

    function getPrices() {
        return [];
    }

Which gives us a new and somewhat confusing error: "AssertionError: expected [] to equal []"

That’s strange, but it’s true. When you check [] === [] in your browser console you get false.
Instead of using to.equal when testing arrays we need to instead use to.eql which performs a deep comparison between them instead.

    describe("get prices", function () {
        it("has no parameters, gives an empty array", function () {
            expect(prices.getPrices()).to.eql([]);
        });
    });

And the test now passes.

get prices
    ✓ with no parameters, gives an empty array

Now that the test is green is when we consider refactoring, to improve the structure of the test and the code. But, we don’t have much to refactor for now.

Next steps

  • :white_check_mark: no function parameter
  • select with no options
  • select with one option
  • select option with valid price
  • select option with multiple prices
  • select option with invalid price

Next up we’ll test some select options that do not meet the criteria for obtaining prices from, which will help improve our ability to supply fake data to the tests.

Because our intention is for the function to get information from the options of a select element, it’s important that we pass a select element.

A select element can have 0, 1, or more options on it, so we want an easy way to create these.

Let’s get started with an empty select element.

Using select

The next test is about when a select element with no options is given to the getPrices function.
Clearly, in that case there are no prices to be obtained, so returning an empty array is the appropriate return value.

        it("has no select options, gives an empty array", function () {
            const select = document.createElement("select");
            expect(prices.getPrices(select)).to.eql([]);
        });

Which passes fine, and helps to expose an issue in our tests.

Refactor getPrices in test

The tests are using prices.getPrices(…) each time. Earlier I said that we want to test that without reaching into the code to use getPrices.

How would we do that? It could be by sefining a select element, attaching an “onchange” event that uses getPrices, to cause something else to change on the page.
We could then test the behaviour of what happens without needing to dig in to the code to use the function.

When we do go that way, there will likely be a lot of historical prices.getPrice() function calls to rename. We can make things a lot easier for us by removing that duplication, and having it in only the one place, so a local getPrices variable is very useful here.

    describe("get prices", function () {
        const getPrices = prices.getPrices;
        it("has no parameters, gives an empty array", function () {
            // expect(prices.getPrices()).to.eql([]);
            expect(getPrices()).to.eql([]);
        });
        it("has no options on select, gives an empty array", function () {
            const select = document.createElement("select");
            // expect(prices.getPrices(select)).to.eql([]);
            expect(getPrices(select)).to.eql([]);
        });
    });

You can ignore all of the comments in my code. They’re only there to show what the line used to be before the change.

Now when we no longer need to use prices.getPrices, it only needs to be changed in the one place.

Remove to.eql

There are a lot of empty array square brackets appearing too. Fortunately for us Chai has an empty method, and even recommends a useful way to check for an empty array:

describe("get prices", function () {
        const getPrices = prices.getPrices;
        it("has no parameters, gives an empty array", function () {
            // expect(getPrices()).to.eql([]);
            expect(getPrices()).to.be.an('array').that.is.empty;
        });
        it("has no options on select, gives an empty array", function () {
            const select = document.createElement("select");
            // expect(getPrices(select)).to.eql([]);
            expect(getPrices(select)).to.be.an('array').that.is.empty;
        });
    });

Giving options

We can use a similar select test with an option added to it. Not only does this help us to add options that we’ll need later, but it also serves as a valuable future warning when undesired behaviour occurs.


        it("has no data-prices attribute on an option, gives an empty array", function () {
            const select = document.createElement("select");
            const option = document.createElement("option");
            option.value = 20;
            option.text = "20 dollars";
            select.add(option);
            expect(getPrices(select)).to.be.an('array').that.is.empty;
        });

The test passes, so it’s time to refactor, which will be the next post.

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • select with one option (refactoring)
  • select option with valid price
  • select option with multiple prices
  • select option with invalid price

With the recent test a lot of setup takes place. We need to move out to a separate helper function.

A createSelect helper function

function createSelect() {
    const select = document.createElement("select");
    const option = document.createElement("option");
    option.value = 20;
    option.text = "20 dollars";
    select.add(option);
    return select;
}
...
    it("has no data-prices attribute on an option, gives an empty array", function () {
        // const select = document.createElement("select");
        // const option = document.createElement("option");
        // option.value = 20;
        // option.text = "20 dollars";
        // select.add(option);
        const select = createSelect();
        expect(getPrices(select)).to.be.an('array').that.is.empty;
    });

No options

I also want to use that createSelect function with the other test that has no option, so I want the function to do different things:

describe("createSelect helper", function () {
    it("with no options, creates a select element with no options", function () {
        const select = createSelect();
        expect(select.options).to.be.empty;
    });
    it("with one set of option params, creates a select with one option", function () {

    });
    it("with multiple option params, creates a select with multiple options", function () {

    });
});

To get the first test just means checking if we have option params:

// function createSelect() {
function createSelect(optionParams) {
    const select = document.createElement("select");
    if (!optionParams) {
        return select;
    }
    const option = document.createElement("option");
    option.value = 20;
    option.text = "20 dollars";
    select.add(option);
    return select;
}

One option

The second test is for giving one option:

    it("creates a select with one option", function () {
        const select = createSelect([
            {value: 30, text: "30 dollars"}
        ]);
        expect(select.options[0].value).to.equal(30);
        expect(select.options[0].text).to.equal("30 dollars");
    });

Which we get going by assigning those values:

function createSelect(optionParams) {
    const select = document.createElement("select");
    if (!optionParams) {
        return select;
    }
    const params = optionParams[0];
    const option = document.createElement("option");
    // option.value = 20;
    option.value = params.value;
    // option.text = "20 dollars";
    option.text = params.text;
    select.add(option);
    return select;
}

And we learn from the test error of "AssertionError: expected '30' to equal 30" that we cannot get numbers back. They are converted to strings when assigned to an element attribute.

        // expect(select.options[0].value).to.equal(30);
        expect(select.options[0].value).to.equal("30");
        expect(select.options[0].text).to.equal("30 dollars");

Many options

The last createSelect test checks that many options can be created:

    it("with multiple option params, creates a select with multiple options", function () {
        const select = createSelect([
            {value: 20, text: "20 dollars"},
            {value: 30, text: "30 dollars"}
        ]);
        expect(select.options[0].value).to.equal("20");
        expect(select.options[0].text).to.equal("20 dollars");
        expect(select.options[1].value).to.equal("30");
        expect(select.options[1].text).to.equal("30 dollars");
    });

And we can easily use a forEach method to achieve that:

function createSelect(optionParams) {
    ...
    optionParams.forEach(function (params) {
        // const params = optionParams[0];
        const option = document.createElement("option");
        option.value = params.value;
        option.text = params.text;
        select.add(option);
    });
    return select;
}

Refactoring

The createSelect helper function now does many things that we’ll want it to do, but some of it can be improved.

Instead of using an if statement to check if optionParams exists, we can just tell the function that it’s to default to an empty array if the function isn’t given anything. That lets us remove the if statement, and everything still works fine.

// function createSelect(optionParams) {
function createSelect(optionParams = []) {
    const select = document.createElement("select");
    // if (!optionParams) {
    //     return select;
    // }
    optionParams.forEach(function (params) {
        const option = document.createElement("option");
        option.value = params.value;
        option.text = params.text;
        select.add(option);
    });
    return select;
}

Using createSelect for good

We can now update the no options test, replacing document.createElement with the createSelect function instead, and be assured that it reliably behaves properly.

    it("has no options on select, gives an empty array", function () {
        // const select = document.createElement("select");
        const select = createSelect();
        expect(getPrices(select)).to.be.an('array').that.is.empty;
    });

And the no data-price test can be updated too:

    it("has no data-prices attribute on an option, gives an empty array", function () {
        // const select = document.createElement("select");
        // const option = document.createElement("option");
        // option.value = 20;
        // option.text = "20 dollars";
        // select.add(option);
        const select = createSelect([
            {value: 20, text: "20 dollars"}
        ]);
        expect(getPrices(select)).to.be.an('array').that.is.empty;
    });

With the commented code removed, we now have much nicer and usable tests.

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • select option with valid price
  • select option with multiple prices
  • select option with invalid price

Right now the getPrices() function just does return []; which isn’t currently all that much. That’s all going to change in the next post.

Working on what happens when the data-price attribute is present, is what we can now work on.

An option with price

The test that we’ll use for checking data-price is, well what would it be?
We would want to add data-price as an attribute to the createSelect function.

What createSelect test do we need for this new functionality?

We can use dataset to get the data-price attribute:

    it("with a price property, creates a select option with data-price attribute", function () {
        const select = createSelect([
            {value: "single-site-license", text: "Single Site License", price: 59}
        ]);
        expect(select.options[0].dataset.price).to.equal("59");
    });

But that’s a train wreck. Literally. There are five things all connected together by periods.
We really should assign an option variable and use that instead.

    it("with a price property, creates a select option with data-price attribute", function () {
        const select = createSelect([
            {value: 10, text: "20 dollar", price: 20}
        ]);
        const option = select.options[0];
        expect(option.dataset.price).to.equal("20");
    });

The test now appropriate tells us on the web page: "AssertionError: expected undefined to equal '59'"

And we can just add an if statement to make the test pass:

function createSelect(optionParams = []) {
    const select = document.createElement("select");
    optionParams.forEach(function (params) {
        const option = document.createElement("option");
        option.value = params.value;
        option.text = params.text;
        if (params.hasOwnProperty("price")) {
            option.dataset.price = params.price;
        }
        select.add(option);
    });
    return select;
}

We’re now ready to write a data-price test for getPrice.

Our first data-price test

    it("data-price on an option, gives that price as an array number", function () {
        const select = createSelect([
            {value: "single-site-license", text: "Single Site License", price: 59}
        ]);
        expect(getPrices(select)).to.eql([59]);
    });

The test fails as it should do, saying "AssertionError: expected [] to equal [ 59 ]"

We can’t have getPrices succeed just returning a 59 array,

    function getPrices(select) {
        return [59];
    }

because several tests need the array to be empty. We can however use those failing tests to get the function working.

No parameters is easy to get working, we just add a select parameter to the function and check if something is there.

    // function getPrices() {
    function getPrices(select) {
        if (select) {
            return [59];
        }
        return [];
    }

The next failing test is about if there’s no option on the select. We can check if options has a length.

    function getPrices(select) {
        // if (select) {
        if (select && select.options.length) {
            return [59];
        }
        return [];
    }

Which leaves only one failing test, for when there is no data-price attribute on the element. We can check if the option’s data has a price attribute.

    function getPrices(select) {
        if (select && select.options.length) {
            const option = select.options[0];
            if (option.dataset.price) {
                return [59];
            }
        }
        return [];
    }

And all of the tests pass.

It is only by doing the previous work with the tests that we gained enough information to help us easily write this code.

I was nearly tempted to update the code to protect against issues with 0 prices. That is instead something for a test to achieve, so I’ll add it to the next steps list.

It’s not nice code, yet, but that’s what refactoring is for. It’s not complete yet either, but that’s what more tests will help us to achieve.

The important thing is that the code it passes all of current tests. We can keep them passing while making lots of changes and improvements to the code too, next time.

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • select option with valid price (refactoring)
  • select option with multiple prices
  • select option with invalid price
  • select option with 0 as a price

The code that we attained last time is in need of some improvement. The tests will help us to ensure that everything still continues to work as desired.

Different option prices

Right now the code is returning [69] so we can have a test ask for a different value, to force the getPrices function to become more general.

    it("has a different price on an option, gives that different price as an array number", function () {
        const select = createSelect([
            {value: "developers-license", text: "Developers License", price: 236}
        ]);
        expect(getPrices(select)).to.eql([236]);
    });

The code now has to return the option price.

    function getPrices(select) {
        if (select && select.options.length) {
            const option = select.options[0];
            if (option.dataset.price) {
                // return [59];
                return [option.dataset.price];
            }
        }
        return [];
    }

But the tests show us that it’s a string being returned.
"AssertionError: expected [ '59' ] to deeply equal [ 59 ]"
"AssertionError: expected [ '236' ] to deeply equal [ 236 ]"

We can instead use the Number object:

    function getPrices(select) {
        if (select && select.options.length) {
            const option = select.options[0];
            if (option.dataset.price) {
                return [Number(option.dataset.price)];
            }
        }
        return [];
    }

And all of the tests pass again.

Refactoring

Instead of returning multiple arrays, I want to start with a single array that I can assign values to, then return that array at the end.

    function getPrices(select) {
        const prices = [];
        ...
        return prices;
    }

The tests still work and we can work on adding the prices to the array.

            if (option.dataset.price) {
                // return [Number(option.dataset.price)];
                prices.push(Number(option.dataset.price));
            }
        }
        return prices;

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • :white_check_mark: select option with valid price
  • :white_check_mark: select option with different price
  • select option with multiple prices
  • select option with invalid price
  • select option with 0 as a price

Next up is to deal with multiple options, in the next post.

Multiple price values

A test for dealing with multiple price values looks like this:

    it("has price on an option, gives that price as an array number", function () {
        const select = createSelect([
            {value: "single-site-license", text: "Single Site License", price: 59},
            {value: "developers-license", text: "Developers License", price: 236}
        ]);
        expect(getPrices(select)).to.eql([236]);
    });

A simple approach to make this pass is to check if there’s more than one option, and return the [59, 236]

    function getPrices(select) {
        if (select && select.options.length) {
            if (select.options.length > 1) {
                return [59, 236];
            }
            const option = select.options[0];
            if (option.dataset.price) {
                return [Number(option.dataset.price)];
            }
        }
        return [];
    }

That works, but it’s not ideal. Now that we have passing tests in the green though, we can refactor and improve the code to our hearts content.

On my first attempt at taking the code forward from here, I tried to improve the [59, 236] code to be similar to the code below it, then transform into a loop. That though resulted in removing a lot of code that has just been built up, so there’s a better way to approach this.

Instead of returning arrays, I want to start with an array and add values to that array, returning that array at the end of the function.

    function getPrices(select) {
        const prices = [];
        ...
        return prices;
    }

We can now add the price to the prices array.

                const option = select.options[0];
                if (option.dataset.price) {
                    // return [Number(option.dataset.price)];
                    prices.push(Number(option.dataset.price));
                }

And we’re now in a good place to turn this in to a loop.

                // const option = select.options[0];
                select.options.forEach(function (option) {
                    if (option.dataset.price) {
                        prices.push(Number(option.dataset.price));
                    }
                // }
                });

But oh no, tests tell us "TypeError: select.options.forEach is not a function". We need to convert select.options

                const options = Array.from(select.options);
                // select.options.forEach(function (option) {
                options.forEach(function (option) {
                    if (option.dataset.price) {
                        prices.push(Number(option.dataset.price));
                    }
                });

We can now remove the one option check:

            // if (select.options.length === 1) {
            const options = Array.from(select.options);
            options.forEach(function (option) {
                if (option.dataset.price) {
                    prices.push(Number(option.dataset.price));
                }
            });
            // }

And remove the more than one options check:

            // if (select.options.length > 1) {
            //     return [59, 236];
            // }
            const options = Array.from(select.options);
            options.forEach(function (option) {
                if (option.dataset.price) {
                    prices.push(Number(option.dataset.price));
                }
            });

We can also simplify the condition around that code too:

        // if (select && select.options.length) {
        if (select) {
            const options = Array.from(select.options);
            options.forEach(function (option) {
                if (option.dataset.price) {
                    prices.push(Number(option.dataset.price));
                }
            });
        }

And we are left with the following working code:

    function getPrices(select) {
        const prices = [];
        if (select) {
            const options = Array.from(select.options);
            options.forEach(function (option) {
                if (option.dataset.price) {
                    prices.push(Number(option.dataset.price));
                }
            });
        }
        return prices;
    }

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • :white_check_mark: select option with valid price
  • :white_check_mark: select option with different price
  • :white_check_mark: select option with multiple prices
  • select option with invalid price
  • select option with 0 as a price

Next up, is checking invalid prices.

Refactoring some tests

Looking at the tests, I see a lot of unwanted lines in the tests like this:

            {value: "single-site-license", text: "Single Site License", price: 59}

Those are added noise that have nothing to do with what the test needs to achieve.
Instead of those, we can assign them to local variables which helps to simplify the tests.

    const singlePrice = 59;
    const singleOption = {value: "single-site-license", text: "Single Site License", price: singlePrice};
    const developerPrice = 236;
    const developerOption = {value: "developers-license", text: "Developers License", price: developerPrice};
    it("has price on an option, gives that price as an array number", function () {
        // const select = createSelect([
        //     {value: "single-site-license", text: "Single Site License", price: 59},
        //     {value: "developers-license", text: "Developers License", price: 236}
        // ]);
        const select = createSelect([singleOption, developerOption]);
        expect(getPrices(select)).to.eql([singlePrice, developerPrice]);
    });

A similar improvement can be made to other tests too.

    it("has price on an option, gives that price as an array number", function () {
        const select = createSelect([singleOption]);
        expect(getPrices(select)).to.eql([singlePrice]);
    });
    it("has a different price on an option, gives that different price as an array number", function () {
        const select = createSelect([developerOption]);
        expect(getPrices(select)).to.eql([developerPrice]);
    });

Invalid price

We can now easily create a test for an invalid price:

    it("has an invalid price, doesn't include the invalid price in the array", function () {
        const invalidOption = {value: "invalid-value", text: "Invalid Value", price: "invalid"};
        const select = createSelect([invalidOption]);
        expect(getPrices(select)).to.eql([]);
    });

and we’re told: "AssertionError: expected [ NaN ] to deeply equal [ ]"

We can check if it’s not a number before adding it to the array.
But first it helps to bring the price out to a separate variable.

                const price = Number(option.dataset.price);
                if (price) {
                    // prices.push(Number(option.dataset.price));
                    prices.push(price);
                }

Now we can easily check if the price is not a number.

                const price = Number(option.dataset.price);
                if (Number.isNaN(price)) {
                    return;
                }
                if (price) {
                    prices.push(price);
                }

And the test passes.

Refactoring the code, thre’s no need for that second if statement anymore.

                const price = Number(option.dataset.price);
                if (Number.isNaN(price)) {
                    return;
                }
                // if (price) {
                prices.push(price);
                // }

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • :white_check_mark: select option with valid price
  • :white_check_mark: select option with different price
  • :white_check_mark: select option with multiple prices
  • :white_check_mark: select option with invalid price
  • select option with 0 as a price

The last thing to deal with is when a price is zero, for that can lead to special circumstances.

What happens with zero?

As zero is a valid price, we want that to be included in the array too.

    it("has zero price, includes the zero price in the array", function () {
        const zeroOption = {value: "zero-license", text: "Zero License", price: "0"};
        const select = createSelect([zeroOption]);
        expect(getPrices(select)).to.eql([0]);
    });

The test passes, which is good confirmation that issues with zero are being properly dealt with.

What about an empty string?

    it("has an empty string for the price, doesn't includes it in the array", function () {
        const emptyOption = {value: "empty-license", text: "Empty License", price: ""};
        const select = createSelect([emptyOption]);
        expect(getPrices(select)).to.eql([]);
    });

The test give us the following error: "expected [ 0 ] to deeply equal []". The code accidentally turns an empty string into a 0.
That’s because Number(“”) is zero. We need to check if it’s empty before passing it through Number.

            options.forEach(function (option) {
                if (option.dataset.price === "") {
                    return;
                }
                const price = Number(option.dataset.price);
                if (Number.isNaN(price)) {
                    return;
                }
                prices.push(price);
            });

Now that the test is passing, we can refactor the code to make it simpler.

I want to use map to get the prices, before iterating over them, but before I can do that I need to reduce how much option.dataset.price is used.

            options.forEach(function (option) {
                const value = option.dataset.price;
                // if (option.dataset.price === "") {
                if (value === "") {
                    return;
                }
                // const price = Number(option.dataset.price);
                const price = Number(value);
                if (Number.isNaN(price)) {
                    return;
                }
                prices.push(price);
            });

I can now map over options getting option.dataset.price, and then loop over that.

            options.map(function (option) {
                return option.dataset.price;
            // options.forEach(function (option) {
            }).forEach(function (value) {
                // const value = option.dataset.price;
                if (value === "") {
                    return;
                }
                ...

We can then filter over the price values, and remove any that are empty.

            options.map(function (option) {
                return option.dataset.price;
            }).filter(function (value) {
                return (value !== "");
            }).forEach(function (value) {
                // if (value === "") {
                //     return;
                // }
                const price = Number(value);
                if (Number.isNaN(price)) {
                    return;
                ...

We can then map over the filtered values converting them to numbers:

            options.map(function (option) {
                return option.dataset.price;
            }).filter(function (value) {
                return (value !== "");
            }).map(function (value) {
                return Number(value);
            // }).forEach(function (value) {
            }).forEach(function (price) {
                // const price = Number(value);
                if (Number.isNaN(price)) {
                    return;
                }
                prices.push(price);
            });

Now that forEach is dealing with prices, we can filter those for any invalid values:

            ...
            }).filter(function (price) {
                return !Number.isNaN(price);
            }).forEach(function (price) {
                // if (Number.isNaN(price)) {
                //     return;
                // }
                prices.push(price);
            });

There’s a lot of chaining going on in the code there now though, so cleaning things up from here will happen in the next post.

Next steps

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • :white_check_mark: select option with valid price
  • :white_check_mark: select option with different price
  • :white_check_mark: select option with multiple prices
  • :white_check_mark: select option with invalid price
  • :white_check_mark: select option with 0 as a price
  • select option with “” as a price (refactoring)

Simplify the code

We can simplifying the code by assigning the prices to a separate variable:

I can’t call it prices though, because that’s already being used to store the array. I can use optionPrices for it in the meantime.

            // options.map(function (option) {
            const optionPrices = options.map(function (option) {
                return option.dataset.price;
            }).filter(function (value) {
                return (value !== "");
            }).map(function (value) {
                return Number(value);
            // }).filter(function (price) {
            });
            optionPrices.filter(function (price) {
                return !Number.isNaN(price);
            }).forEach(function (price) {

That prices array isn’t needed any more. Instead of forEach we can just return the array of filtered prices.

            // optionPrices.filter(function (price) {
            return optionPrices.filter(function (price) {
                return !Number.isNaN(price);
            // }).forEach(function (price) {
            //     prices.push(price);
            //     return prices;
            });

That lets us remove the prices array. We can invert the if statement and do an early return of an empty array, before doing the rest of the code.

    function getPrices(select) {
        // const prices = [];
        // if (select) {
        if (!select) {
            return [];
        }
        const options = Array.from(select.options);
        const optionPrices = options.map(function (option) {
        ...
        return optionPrices.filter(function (price) {
            return !Number.isNaN(price);
        });
        // }
        // return prices;

This lets us rename optionPrices back to prices, and the code is now dramatically improved.

        // const optionPrices = options.map(function (option) {
        const prices = options.map(function (option) {
            return option.dataset.price;
        }).filter(function (value) {
            return (value !== "");
        }).map(function (value) {
            return Number(value);
        });
        // return optionPrices.filter(function (price) {
        return prices.filter(function (price) {
            return !Number.isNaN(price);
        });

One last potential improvement is to move the functions to arrow-notation functions instead, and we’re left with the following tests:

describe("createSelect helper", function () {
    it("with no options, creates a select element with no options", function () {
        const select = createSelect();
        expect(select.options).to.be.empty;
    });
    it("with one set of option params, creates a select with one option", function () {
        const select = createSelect([
        {value: 30, text: "30 dollars"}
        ]);
        expect(select.options[0].value).to.equal("30");
        expect(select.options[0].text).to.equal("30 dollars");
    });
    it("with multiple option params, creates a select with multiple options", function () {
        const select = createSelect([
        {value: 20, text: "20 dollars"},
        {value: 30, text: "30 dollars"}
        ]);
        expect(select.options[0].value).to.equal("20");
        expect(select.options[0].text).to.equal("20 dollars");
        expect(select.options[1].value).to.equal("30");
        expect(select.options[1].text).to.equal("30 dollars");
    });
    it("with a price property, creates a select option with data-price attribute", function () {
        const select = createSelect([
            {value: 10, text: "20 dollar", price: 20}
        ]);
        const option = select.options[0];
        expect(option.dataset.price).to.equal("20");
    });
});
describe("get prices", function () {
    const getPrices = prices.getPrices;
    const singlePrice = 59;
    const singleOption = {value: "single-site-license", text: "Single Site License", price: singlePrice};
    const developerPrice = 236;
    const developerOption = {value: "developers-license", text: "Developers License", price: developerPrice};
    it("has no parameters, gives an empty array", function () {
        expect(getPrices()).to.be.an('array').that.is.empty;
    });
    it("has no options on select, gives an empty array", function () {
        const select = createSelect();
        expect(getPrices(select)).to.be.an('array').that.is.empty;
    });
    it("has no data-price attribute on an option, gives an empty array", function () {
        const select = createSelect([
            {value: 20, text: "20 dollars"}
        ]);
        expect(getPrices(select)).to.be.an('array').that.is.empty;
    });
    it("has price on an option, gives that price as an array number", function () {
        const select = createSelect([singleOption]);
        expect(getPrices(select)).to.eql([singlePrice]);
    });
    it("has a different price on an option, gives that different price as an array number", function () {
        const select = createSelect([developerOption]);
        expect(getPrices(select)).to.eql([developerPrice]);
    });
    it("has price on an option, gives that price as an array number", function () {
        const select = createSelect([singleOption, developerOption]);
        expect(getPrices(select)).to.eql([singlePrice, developerPrice]);
    });
    it("has an invalid price, doesn't include the invalid price in the array", function () {
        const invalidOption = {value: "invalid-value", text: "Invalid Value", price: "invalid"};
        const select = createSelect([invalidOption]);
        expect(getPrices(select)).to.eql([]);
    });
    it("has zero price, includes the zero price in the array", function () {
        const zeroOption = {value: "zero-license", text: "Zero License", price: "0"};
        const select = createSelect([zeroOption]);
        expect(getPrices(select)).to.eql([0]);
    });
    it("has an empty string for the price, doesn't includes it in the array", function () {
        const emptyOption = {value: "empty-license", text: "Empty License", price: ""};
        const select = createSelect([emptyOption]);
        expect(getPrices(select)).to.eql([]);
    });
});

Which test and support the following getPrices function.

    function getPrices(select) {
        if (!select) {
            return [];
        }
        const optionPrice = (option) => option.dataset.price;
        const hasValue = (value) => value !== "";
        const toNumber = (value) => Number(value);
        const validNumber = (price) => !Number.isNaN(price);
        
        const options = Array.from(select.options);
        const optionPrices = options.map(optionPrice).filter(hasValue).map(toNumber);
        return optionPrices.filter(validNumber);
    }

Next steps

There are no next steps now. Everything’s been taken care of.

  • :white_check_mark: no function parameter
  • :white_check_mark: select with no options
  • :white_check_mark: select with one option
  • :white_check_mark: select option with valid price
  • :white_check_mark: select option with different price
  • :white_check_mark: select option with multiple prices
  • :white_check_mark: select option with invalid price
  • :white_check_mark: select option with 0 as a price
  • :white_check_mark: select option with “” as a price

The tests cover all of our concerns, making this a pretty good place to leave things for the getPrices function.

This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.