diff --git a/07 Unit Testing.md b/07 Unit Testing.md index 6242a6e7..8b2d41ba 100644 --- a/07 Unit Testing.md +++ b/07 Unit Testing.md @@ -17,7 +17,7 @@ We will be using a tool called [Jest](https://jestjs.io) which was originally de READ THE FOLLOWING CAREFULLY -**In the previous labs you have opened the `foundation` directory in VS Code and hd access to all the files and subfolders. The testing tools require you to open the folder containing the project we want to test directly so you will need to use the file menu and open the `foundation/exercises/07_unit_testing/todo/` directory.** +**In the previous labs you have opened the `foundation` directory in VS Code and had access to all the files and subfolders. The testing tools require you to open the folder containing the project we want to test directly so you will need to use the file menu and open the `foundation/exercises/07_unit_testing/todo/` directory.** The project has a number of node package dependencies which are listed in the `package.json` file. Start by installing all of these and then you should start the server and have a look at the website. As you can see it is a simple todo list, try adding a few items and deleting them, you will see that only some of the functionality has been implemented! @@ -110,7 +110,7 @@ Any code highlighted in red is not covered by your test suite. ### 1.4 Running the Tests Using Visual Studio Code -In the previous section you learned how to run a test suite and check code coverage just using the CLI (terminal) and this will work regardless of the environment you are using. In this section you will learn how to run your test suite using VS Code together with a feww useful extensions. +In the previous section you learned how to run a test suite and check code coverage just using the CLI (terminal) and this will work regardless of the environment you are using. In this section you will learn how to run your test suite using VS Code together with a few useful extensions. ### 1.5 Visual Studio Code Extensions @@ -268,11 +268,11 @@ Here is a possible solution: ```javascript module.exports.add = (item, qty) => { qty = Number(qty) - if(isNaN(qty)) throw new Error('the quantity must be a number') + if(isNaN(qty)) throw new Error('qty must be a number') let flag = false for(let index in data) { if (data[index].item === item) { - data[index].qty+= qty + data[index].qty += qty flag = true } } @@ -295,11 +295,11 @@ There is not a lot we can do to the program code: ```javascript module.exports.add = (item, qty) => { qty = Number(qty) - if(isNaN(qty)) throw new Error('the quantity must be a number') + if(isNaN(qty)) throw new Error('qty must be a number') let flag = false for(const index in data) { if (data[index].item === item) { - data[index].qty+= qty + data[index].qty += qty flag = true } } @@ -327,7 +327,7 @@ Run the test suite to check there are no errors. }) ``` -Again, check that all the test still pass. As a final check start the web server and see if it works in the browser. Congratulations, you have now completed your first TDD iteration. +Again, check that all the tests still pass. As a final check start the web server and see if it works in the browser. Congratulations, you have now completed your first TDD iteration. ### 1.8 Test Your Understanding @@ -378,7 +378,7 @@ In this version we export a NodeJS [Class](https://developer.mozilla.org/en-US/d The `unit tests/todo.js` script contains the same tests as the array-based version but each test now: -1. Uses an object constructor to get a `ToDo` object asynchronouly. +1. Uses an object constructor to get a `ToDo` object asynchronously. 2. Calls the appropriate async method(s). ### 2.2 In-Memory Databases @@ -419,10 +419,10 @@ You will now complete a few more TDD iterations: 1. What happens if you leave the item box empty? This should throw an error, not add a blank item. 2. What happens if you leave the qty box empty? Solve this in a similar way. -3. What happens if you click on one of the **Delete** links? Implement this feature. Remember that since this is testing the `delete()` function you need to create a new _test suite_ called `delete()` in the same test suite. +3. What happens if you click on one of the **Delete** links? Implement this feature. Remember that since this is testing the `delete()` function you need to create a new _test suite_ called `delete()` in the same unit test script. 4. Can you write one or more tests for the `getAll()` function? 5. And for the `clear()` function as well. As before, are you gettting 100% code coverage? If not, write more tests. Are you covering the edge cases and checking for correct handling of bad data? -Note that there appears to be a bug in the VS Code debugger when stepping through a function that returns a promise. If the debugger sends you to a script called `async_hooks.js` you can get stuck in an loop. When the debugger is highlighting the closing brace of an async function press the **Step Out** button a few times (typically) to return to the parent function. Remember you can add breakpoints to the test as well as the module code. +Note that there appears to be a bug in the VS Code debugger when stepping through a function that returns a promise. If the debugger sends you to a script called `async_hooks.js` you can get stuck in a loop. When the debugger is highlighting the closing brace of an async function press the **Step Out** button a few times (typically) to return to the parent function. Remember you can add breakpoints to the test as well as the module code. diff --git a/exercises/07_unit_testing/database/modules/todo.js b/exercises/07_unit_testing/database/modules/todo.js index 8e6309e7..3b74179d 100644 --- a/exercises/07_unit_testing/database/modules/todo.js +++ b/exercises/07_unit_testing/database/modules/todo.js @@ -17,9 +17,7 @@ module.exports = class ToDo { async add(item, qty) { qty = Number(qty) if(isNaN(qty)) throw new Error('the quantity must be a number') - let sql = 'SELECT * FROM items;' - // const dataAll = await this.db.all(sql) - sql = `SELECT * FROM items WHERE ITEM = "${item}"` + let sql = `SELECT * FROM items WHERE ITEM = "${item}"` const data = await this.db.all(sql) if(data.length === 0) { sql = `INSERT INTO items(item, qty) VALUES("${item}", ${qty})` @@ -49,4 +47,7 @@ module.exports = class ToDo { return data.items } + async clear() { + await this.db.run('DELETE FROM items') + } } diff --git a/solutions/07_unit_testing/database/todo.js b/solutions/07_unit_testing/database/todo.js new file mode 100644 index 00000000..106f408d --- /dev/null +++ b/solutions/07_unit_testing/database/todo.js @@ -0,0 +1,59 @@ + +'use strict' + +const sqlite = require('sqlite-async') + +module.exports = class ToDo { + + constructor(dbName = ':memory:') { + return (async() => { + this.db = await sqlite.open(dbName) + const sql = 'CREATE TABLE IF NOT EXISTS items(id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT, qty NUMERIC)' + await this.db.run(sql) + return this + })() + } + + async add(item, qty) { + if (item === '') throw new Error('item cannot be empty') + if (qty === '') throw new Error('qty cannot be empty') + qty = Number(qty) + if(isNaN(qty)) throw new Error('the quantity must be a number') + let sql = `SELECT * FROM items WHERE ITEM = "${item}"` + const data = await this.db.all(sql) + if(data.length === 0) { + sql = `INSERT INTO items(item, qty) VALUES("${item}", ${qty})` + await this.db.run(sql) + } else { + const newQty = data[0].qty + qty + sql = `UPDATE items SET qty=${newQty} WHERE ITEM = "${item}"` + await this.db.run(sql) + } + } + + async getAll() { + const sql = 'SELECT * FROM items' + const data = await this.db.all(sql) + console.log(data) + return data + } + + async delete(id) { + if (id === undefined) throw new Error('id cannot be undefined') + id = Number(id) + if (isNaN(id)) throw new Error('id must be a number') + const sql = `DELETE FROM items WHERE id=${id}` + if ((await this.db.run(sql)).changes === 0) throw new Error('id has to exist') + } + + async countItems() { + const sql = 'SELECT COUNT(*) as items FROM items' + const data = await this.db.get(sql) + return data.items + } + + async clear() { + await this.db.run('DELETE FROM items') + } + +} diff --git a/solutions/07_unit_testing/database/todo.spec.js b/solutions/07_unit_testing/database/todo.spec.js new file mode 100644 index 00000000..7a3663af --- /dev/null +++ b/solutions/07_unit_testing/database/todo.spec.js @@ -0,0 +1,163 @@ + +'use strict' + +const ToDo = require('../modules/todo.js') + +beforeAll( async() => { + // stuff to do before any of the tests run +}) + +afterAll( async() => { + // runs after all the tests have completed +}) + +describe('add()', () => { + // block of tests + // beforeEach( async() => { + // todo.clear() + // }) + afterEach( async() => { + // runs after each test completes + }) + test('add a single item', async done => { + expect.assertions(1) + // ARRANGE + const todo = await new ToDo() // DB runs in-memory if no name supplied + // ACT + await todo.add('bread', 3) + const count = await todo.countItems() + // ASSERT + expect(count).toBe(1) + done() + }) + + test('qty must be a number', async done => { + expect.assertions(1) + const todo = await new ToDo() + await expect( todo.add('bread', 'three') ).rejects.toEqual( Error('the quantity must be a number') ) + done() + }) + + test('duplicates should increase qty', async done => { + expect.assertions(2) + // ARRANGE + const todo = await new ToDo() + // ACT + await todo.add('bread', 4) + await todo.add('bread', 2) + const data = await todo.getAll() + const qty = data[0].qty + // ASSERT (note there are two assertions as stated on line 42) + expect(data).toHaveLength(1) + expect(qty).toEqual(6) + done() + }) + + test('item cannot be empty', async done => { + expect.assertions(1) + const todo = await new ToDo() + await expect( todo.add('', 1) ).rejects.toEqual( Error('item cannot be empty') ) + done() + }) + + test('qty cannot be empty', async done => { + expect.assertions(1) + const todo = await new ToDo() + await expect( todo.add('bread', '') ).rejects.toEqual( Error('qty cannot be empty') ) + done() + }) + +}) + +describe('delete()', () => { + beforeEach( async() => { + this.todo = await new ToDo() + await this.todo.add('bread', 5) + }) + + test('id cannot be undefined', async done => { + expect.assertions(1) + await expect( this.todo.delete() ).rejects.toEqual( Error('id cannot be undefined') ) + done() + }) + + test('id must be a number', async done => { + expect.assertions(1) + await expect( this.todo.delete('a') ).rejects.toEqual( Error('id must be a number') ) + done() + }) + + test('id has to exist', async done => { + expect.assertions(1) + await expect( this.todo.delete(2) ).rejects.toEqual( Error('id has to exist') ) + done() + }) + + test('delete an item', async done => { + expect.assertions(2) + await this.todo.delete(1) + expect(await this.todo.countItems()).toEqual(0) + expect(await this.todo.getAll()).toEqual([]) + done() + }) +}) + +describe('getAll()', () => { + beforeEach( async() => { + this.todo = await new ToDo() + }) + + test('getAll() with no items', async done => { + expect.assertions(1) + expect(await this.todo.getAll()).toEqual([]) + done() + }) + + test('getAll() with a single item', async done => { + expect.assertions(1) + await this.todo.add('bread', 2) + expect(await this.todo.getAll()).toEqual([{"item":"bread", "qty":2, "id":1}]) + done() + }) + + test('getAll() with two items', async done => { + expect.assertions(1) + await this.todo.add('bread', 2) + await this.todo.add('ham', 3) + expect(await this.todo.getAll()).toEqual([{"item":"bread", "qty":2, "id":1}, {"item":"ham", "qty":3, "id":2}]) + done() + }) +}) + +describe('clear()', () => { + beforeEach( async() => { + this.todo = await new ToDo() + }) + + test('clear() with no items', async done => { + expect.assertions(2) + await this.todo.clear() + expect(await this.todo.countItems()).toEqual(0) + expect(await this.todo.getAll()).toEqual([]) + done() + }) + + test('clear() with a single item', async done => { + expect.assertions(2) + await this.todo.add('bread', 2) + await this.todo.clear() + expect(await this.todo.countItems()).toEqual(0) + expect(await this.todo.getAll()).toEqual([]) + done() + }) + + test('clear() with two items', async done => { + expect.assertions(2) + await this.todo.add('bread', 2) + await this.todo.add('ham', 3) + await this.todo.clear() + expect(await this.todo.countItems()).toEqual(0) + expect(await this.todo.getAll()).toEqual([]) + done() + }) +}) diff --git a/solutions/07_unit_testing/todo/todo.js b/solutions/07_unit_testing/todo/todo.js new file mode 100644 index 00000000..844c2a62 --- /dev/null +++ b/solutions/07_unit_testing/todo/todo.js @@ -0,0 +1,43 @@ + +'use strict' + +let data = [] + +module.exports.clear = () => { + data = [] +} + +module.exports.add = (item, qty) => { + if (item === '') throw new Error('item cannot be empty') + if (qty === '') throw new Error('qty cannot be empty') + qty = Number(qty) + if(isNaN(qty)) throw new Error('qty must be a number') + let flag = false + for(const index in data) { + if (data[index].item === item) { + data[index].qty += qty + flag = true + } + } + if(flag === false) data.push({item: item, qty: qty}) +} + +module.exports.getAll = () => { + for(const key in data) data[key].key = Number(key) + return data +} + +module.exports.delete = key => { + console.log(`delete key ${key}`) + if (key === undefined) throw new Error('key cannot be undefined') + key = Number(key) + if (isNaN(key)) throw new Error('key must be a number') + try { + this.getAll()[key].key // throws error unless the item with the target key exists + data.splice(key, 1) + } catch { + throw new Error('key has to exist') + } +} + +module.exports.countItems = () => data.length diff --git a/solutions/07_unit_testing/todo/todo.spec.js b/solutions/07_unit_testing/todo/todo.spec.js new file mode 100644 index 00000000..a0bdc7d4 --- /dev/null +++ b/solutions/07_unit_testing/todo/todo.spec.js @@ -0,0 +1,242 @@ + +'use strict' + +const todo = require('../modules/todo.js') + +beforeAll( async() => { + // stuff to do before any of the tests run +}) + +afterAll( async() => { + // runs after all the tests have completed +}) + +describe('add()', () => { + // block of tests + beforeEach( async() => { + todo.clear() + }) + afterEach( async() => { + // runs after each test completes + }) + test('add a single item', async done => { + expect.assertions(1) + try { + todo.add('bread', 3) + expect(todo.countItems()).toBe(1) + } catch(err) { + done.fail(err) + } finally { + done() + } + }) + test('qty must be a number', async done => { + expect.assertions(1) + try { + todo.add('bread', 'three') + done.fail('test failed') + } catch(err) { + expect(err.message).toBe('qty must be a number') + } finally { + done() + } + }) + + test('duplicates should increase qty', async done => { + expect.assertions(2) + try { + // ACT + todo.add('bread', 4) + todo.add('bread', 2) + // ASSERT + const count = todo.countItems() + expect(count).toBe(1) + const data = todo.getAll() + const qty = data[0].qty + expect(qty).toEqual(6) + } catch(err) { + done.fail('test failed') + } finally { + done() + } + }) + + test('item cannot be empty', async done => { + expect.assertions(1) + try { + todo.add('', 1) + done.fail('test failed') + } catch(err) { + expect(err.message).toBe('item cannot be empty') + } finally { + done() + } + }) + + test('qty cannot be empty', async done => { + expect.assertions(1) + try { + todo.add('bread', '') + done.fail('test failed') + } catch(err) { + expect(err.message).toBe('qty cannot be empty') + } finally { + done() + } + }) +}) + +describe('delete()', () => { + beforeEach( async() => { + todo.clear() + todo.add('bread', 2) + }) + afterEach( async() => { + }) + + test('key cannot be undefined', async done => { + expect.assertions(1) + try { + todo.delete() + done.fail('test failed') + } catch(err) { + expect(err.message).toBe('key cannot be undefined') + } finally { + done() + } + }) + + test('key must be a number', async done => { + expect.assertions(1) + try { + todo.delete('a') + done.fail('test failed') + } catch(err) { + expect(err.message).toBe('key must be a number') + } finally { + done() + } + }) + + test('key has to exist', async done => { + expect.assertions(1) + try { + todo.delete(1) + done.fail('test failed') + } catch(err) { + expect(err.message).toBe('key has to exist') + } finally { + done() + } + }) + + test('delete an item', async done => { + expect.assertions(1) + try { + todo.delete(0) + expect(todo.getAll()).toEqual([]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) +}) + +describe('getAll()', () => { + beforeEach( async() => { + todo.clear() + }) + afterEach( async() => { + }) + + test('getAll() with no items', async done => { + expect.assertions(1) + try { + expect(todo.getAll()).toEqual([]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) + + test('getAll() with a single item', async done => { + expect.assertions(1) + try { + todo.add('bread', 2) + expect(todo.getAll()).toEqual([{"item":"bread", "qty":2, "key":0}]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) + + test('getAll() with two items', async done => { + expect.assertions(1) + try { + todo.add('bread', 2) + todo.add('ham', 3) + expect(todo.getAll()).toEqual([{"item":"bread", "qty":2, "key":0}, {"item":"ham", "qty":3, "key":1}]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) +}) + +describe('clear()', () => { + beforeEach( async() => { + todo.clear() + }) + afterEach( async() => { + }) + + test('clear() with no items', async done => { + expect.assertions(1) + try { + todo.clear() + expect(todo.getAll()).toEqual([]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) + + test('clear() with 1 item', async done => { + expect.assertions(1) + try { + todo.add('bread', 2) + todo.clear() + expect(todo.getAll()).toEqual([]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) + + test('clear() with 2 items', async done => { + expect.assertions(1) + try { + todo.add('bread', 2) + todo.add('ham', 3) + todo.clear() + expect(todo.getAll()).toEqual([]) + } catch(err) { + done.fail('test failed') + console.log(err) + } finally { + done() + } + }) +})