A Hands-on Sample Project On Front-End Unit Testing Using KARMA And JASMINE.
In this Karma Tutorial Series, we explored all about Jasmine Framework in our previous tutorial.
This tutorial explains how to put into practice the theoretical knowledge that we have gained from our previous tutorials in this series about Karma, Jasmine, and Jasmine-Jquery.
We will also see how to use other tools like gulp and browserify to make our test implementation effective and easier. To do that we would select a sample project and work with it. The sample project is a simple CGPA calculator.
Table of Contents:
Sample Project – Definition, Analysis & Test Conditions
The project that we are using as an instance in this learning is a very simple one. It is going to be a simple student’s Cumulative Grade Point Average (CGPA), calculator.
Analysis
Problem Definition: Computerization of CGPA computation procedure.
Requirements: When we provide a student’s registered set of courses with their credit load and grades, the system should be able to compute the student’s CGPA for the given semester.
Take the grading system as:
A – 5
B – 4
C – 3
D – 2
E – 1
F – 0
Sample Computation
Given that Caroline is a 200L student of the Computer Science Department, who is studying the below-mentioned courses for the Rain Semester. Compute her CGPA accordingly.
Course Details To Use In Sample Project
Courses Title | Course Code | Credit Load | Grade |
---|---|---|---|
Introduction to Java Programming | CSC201 | 3 | A |
Introduction to Systems Programming | CSC202 | 4 | A |
Mathematical Methods 1 | MTH201 | 3 | B |
Operations Research 1 | MTH203 | 3 | B |
Real Analysis 1 | MTH 204 | 3 | C |
Introduction to Compiler Construction | CSC 205 | 4 | B |
Survey of Programming Languages | CSC 206 | 3 | B |
Databases 1 | CSC 207 | 3 | C |
Total Credit Load | 26 |
CGPA = (Credit Load X Grade Point) / Total Credit Load; where n is the number of courses registered for the student for the semester.
CGPA = (15 + 20 +12 + 12 + 9 + 16 + 12 + 9)/ 26 = 105 / 26 = 4.03
Expected Form of Input and Output: All Student details should be entered in a user-friendly web interface and the output should also be displayed in the same form.
Expected Behavior of System: When I enter a student’s name, select their level, and then the semester, a set of checkbox tags should automatically be visible on the page.
These checkboxes will bear the courses (together with their credit load) offered to the student by the University at the selected level for the semester chosen.
The only detail which the user has to enter is the acquired grade corresponding to each course. The rest of the details required for the computation should be fetched from a statically saved JSON file.
To make it more presentable, you can implement it as a table grid with tags within for different expected features.
Our Possible Test Conditions for the Front-End of the Application:
- Given that I am using the application, I have entered a student’s name, student’s registration number, select their level of study, their department and their current semester, then a table should be displayed containing the fields i.e. code, title, units, score, grade, and points.
Each course details should be treated as one complete row. The fields code, title, units, grade, and points should be made read-only as they are auto-generated. Grade and points for each row should be computed immediately when the score input field for a particular row loses its focus.
- In the displayed output, the generated table showing each registered course and the acquired points should be tested to see if the points produced are accurate, the total computed points are accurate and the CGPA computed is accurate as well.
- Also, remember to test the use of spies for functions that are dependent on other functions. For example, the function for computing a single course point acquired is dependent on the function for setting grade.
- A test should be written to ensure that the courses returned from the JSON file are not an empty object and that it is well-formatted too.
Implementing The Project Using Jasmine Standalone Distribution And Gulp
As mentioned in the previous tutorial there are two ways to use Jasmine. We are going to learn the implementation of one of the ways in this section.
The Jasmine standalone distribution that you downloaded in the previous tutorial has the directory structure as shown in the below figure.
Directory Structure Of Jasmine Standalone Distribution Zip File
In the above image, you can see the SpecRunner.html file. This file displays the results of the tests in the browser. As this file needs to be displayed in a web browser, there is a need to configure a server. The gulp package helps us with the need for configuring the server.
The lib folder contains the Jasmine library files that facilitate the specRunner to display the output of the tests. The spec folder contains the test files and the src folder contains the javascript source codes that are required to make the tests pass.
Note that the standalone distribution still needs the Jasmine package installed with npm for it to work.
Note: If you prefer, you can download the code for the sample project from The Github Repo here.
#1) Modifying SpecRunner.html And Directory Structure
Now, we need to delete the default files from the test folder and src folder and then create our own files. We also need to create our own folder called ‘JSON’ which will contain the course object in a JSON file. Hence, create a JSON file named ‘courses.json’.
As we have changed the directory structure, we need to modify our specRunner.html as well to make it as the code shown below.
<html> <head> <metacharset="utf-8"> <title>Jasmine Spec Runner v2.5.2</title> <linkrel="shortcut icon"type="image/png"href="lib/jasmine-2.5.2/jasmine_favicon.png"> <linkrel="stylesheet"href="lib/jasmine-2.5.2/jasmine.css"> <scriptsrc="lib/jasmine-2.5.2/jasmine.js"></script> <scriptsrc="lib/jasmine-2.5.2/jasmine-html.js"></script> <scriptsrc="lib/jasmine-2.5.2/boot.js"></script> <!-- include source files here... --> <scriptsrc="src/Cgpa.js"></script> <!-- include spec files here... --> <scriptsrc="spec/CgpaSpec.js"></script> </head> <body> </body> </html>
Note that in the code, the Cgpa.js source file (that contains all our functions) is loaded first before the spec file, as the CgpaSpec.js file depends on Cgpa.js. Hence it must be specified first for the browser to load it first.
Now copy the absolute path of your specRunner.html and paste it into your browser address bar as shown in the below figure.
Copying The Absolute Path Of The Jasmine SpecRunner.html File
You will discover the specRunner informing you that “no spec was found” as we included the spec file in the head tag.
To know why simply inspect the web page and view the console tab. You will see the error popped up. This is because we used the require statement which the browser doesn’t understand.
To solve this problem, let me introduce you to a task runner called gulp.
#2) Writing The gulpfile.js File
Gulp is a node-based task runner that helps to run a wide range of time-consuming tasks such as File bundling, File or module loading, File transformation, Server creation, etc. during development.
In our case, we are going to use gulp to create a server on which we will always start our specRunner, browserify (our spec file) and then add it to our specRunner.html and further watch for changes in any of the files and continue to repeat the bundling. Click here to know more about gulp.
To get started with it, simply run the command ‘npm install gulp –save-dev’ in the command line.
To create a server on which we will serve our specRunner, we need the package gulp-connect. You can learn more about gulp-connect here. And for bundling, we need to install browserify.
Another package that we would require is the vinyl-source-stream. It is used for transforming readable file streams into vinyl objects.
Vinyl objects are simple metadata objects which describe that the file was transformed. Check out these two links i.e. here and here for more details. Now run the command ‘npm install vinyl-source-stream browserify gulp-connect –save-dev’.
The file that will contain the configurations for performing the above-listed tasks is usually named as gulpfile.js. Hence, create a file with this name and add the code shown below to its content.
var gulp = require('gulp'); var connect = require('gulp-connect'); var browserify = require('browserify'); var source = require('vinyl-source-stream'); gulp.task('connect', function() { connect.server({ root:'./', livereload:true, port:8004 }); }); gulp.task('html', function () { gulp.src('./*.html') .pipe(connect.reload()); }); /* browserify */ gulp.task('browserify', function() { browserify({ entries:'./spec/CgpaSpec.js', debug:true }) .bundle() .pipe(source('bundleSpec.js')) .pipe(gulp.dest('./bundle/')); }); gulp.task('watch', function () { gulp.watch(['./*.html'], ['html']); gulp.watch(['./spec/*.js','./src/*.js'], ['browserify', 'html']); }); gulp.task('default', ['connect', 'browserify', 'watch']);
In a nutshell, whenever I run the command gulp, the default task runs, which runs the connect task, followed by browserify and then watch.
The connect task starts a server connection at the port above, the browserify tasks takes care of bundling the spec file and the watch task monitors the js, and html files for changes, and then runs the html task to refresh the browser or the browserify task to rebundle and the HTML to refresh the specRunner.html on the browser.
The browserify task bundles the spec file and sends the output to a new file called bundleSpec.js. Hence, we need to replace the CgpaSpec.js file with this new file in our specRunner.html.
Bundling simply means taking an external file and fixing it into another file where it is brought in using the ‘require’ or the ‘import’ keyword depending on whether we are coding with es6 or es5 style. You will hear more about this in the last section of this article.
Now create a script called specRunner in the ‘package.json’ file and assign gulp to it as shown below.
{ "name": "basicut", "version": "1.0.0", "description": "An Angular Application that demonstrates how to use karma and jasmine for front-end unit testing", "main": "index.js", "scripts": { "test": "karma start", "specRunner": "gulp", "karmaTest": "karma start" }, "repository": { "type": "git", "url": "git+https://github.com/elahsoft/Front-End-Unit-Testing-JQuery-Web-Application.git" }, "keywords": [ "angularjs", "karma", "jasmine", "testing", "front-end" ], "author": "OPARA FEBECHUKWU CHINONYEREM", "license": "ISC", "bugs": { "url": https://github.com/elahsoft/Front-End-Unit-Testing--Karma-Jasmine-Angular/issues }, "homepage": "https://github.com/elahsoft/Front-End-Unit-Testing--Karma-Jasmine-Angular#readme", "devDependencies": { "babel-loader": "^6.4.1", "babelify": "^7.3.0", "brfs": "^1.4.3", "browserify": "^14.1.0", "gulp": "^3.9.1", "gulp-connect": "^5.0.0", "jasmine-core": "^2.5.2", "jasmine-jquery": "^2.1.1", "karma": "^1.5.0", "karma-browserify": "^5.1.1", "karma-chrome-launcher": "^2.0.0", "karma-firefox-launcher": "^1.0.1", "karma-jasmine": "^1.1.0", "karma-phantomjs-launcher": "^1.0.4", "vinyl-source-stream": "^1.1.0", "watchify": "^3.9.0" }, "dependencies": { "jquery": "^3.2.1", "underscore": "^1.8.3" } }
Now, everything runs fine and the server started at port 8004. When we visit the localhost:8004, we can see the directory structure of our project, and when we click on specRunner.html on the browser page, we will get the report “No spec found”.
Hence to ascertain that all configurations work fine, we need to create at least a dummy test in our CgpaSpec.js file. So, add this to the file:
var courses = require ('../json/courses.json'); describe('Testing that the json file is not empty', function() { beforeEach( function () { varisEmpty = true; if (Object.keys(courses).length&amp;gt;0) { isEmpty = false; } }); it('json file should not be empty', function() { expect(Object.keys(courses).length).toBeGreaterThan(0); }); it('test that the isEmpty function returns same as above', function() { expect(isEmpty).toBeFalsy(); expect(isEmptyCourses()).toBeFalsy(); }); });
In the code snippet above, we are using the beforeEach construct to set the isEmpty variable. I hope you can interpret the rest of the code with your JS knowledge.
Then, we made use of two ‘it’ construct, to write the specs that test the JSON file is not empty and the function that we are using for it always returns false for files that not empty and always returns true for the files that are empty.
Now run npm run specRunner from your command line, and everything runs fine and terminates with this error: events.js:160 throw er; // Unhandled ‘error’ event.
This error comes up as we are loading an empty file. Hence, just simply create the JSON file to hold an empty object {} and rerun. You will see everything to work fine.
Now you can see the bundled file bundleSpec.js in the spec directory. We need to replace it as the spec file in our specRunner.html, which becomes:
<!DOCTYPE html> <html> <head> <metacharset="utf-8"> <title>Jasmine Spec Runner v2.5.2</title> <linkrel="shortcut icon"type="image/png"href="lib/jasmine-2.5.2/jasmine_favicon.png"> <linkrel="stylesheet"href="lib/jasmine-2.5.2/jasmine.css"> <scriptsrc="lib/jasmine-2.5.2/jasmine.js"></script> <scriptsrc="lib/jasmine-2.5.2/jasmine-html.js"></script> <scriptsrc="lib/jasmine-2.5.2/boot.js"></script> <!-- include source files here... --> <!--<script src="json/course.json"></script>--> <scriptsrc="src/Cgpa.js"></script> <!-- include spec files here... --> <scriptsrc="bundle/bundleSpec.js"></script> </head> <body> </body> </html>
Now re-run the tests, and you will see that everything works fine with the tests failing as shown in the image below.
SpecRunner Working With Failing Tests
Next is writing the rest of the required test suites.
#3) Writing Tests Using Jasmine And Jasmine-jquery Constructs
Step I: Import the courses.json file
To do this we use the statement: var courses = require (‘../json/courses.json’);
The code imports or loads the ‘courses.json’ file from the given path. This path indicates that I am currently in the spec folder and I should step out of it (i.e. ../). Hence, go into the ‘JSON’ folder, and then find the ‘courses.json file’.
Now use your intuition and create the file, but make it contain an empty object as we need to run the test first and have it fail and then implement the feature, and have it pass.
To use the ‘require’ keyword to load an external file into our JavaScript code, we need to use a file/module loading (bundling) package like requirejs (remember above?), or browserify.
Step II: Import other dummy JSON objects which will be used to test the functions that ascertain that our course object is properly formatted.
Step III: Use the constructs learned in the previous lessons to write the tests to verify the fulfillment of the expected behavior of the system as specified above in the project definition section. See the code below.
var courses = require('../json/courses.json'); var illFormatedCourses = require('../json/illFormattedCourses.json'); var nonObjectCourses = require('../json/nonObjectCourses.json'); var cGPACalculator = new CGPACalculator(); //Testing without Fixtures describe('Testing that the json file is not empty', function () { var isEmpty = true; beforeEach(function () { if (typeof (courses) === "object"){ if (Object.keys(courses).length &amp;gt; 0) { isEmpty = false; } } }); it('json file should not be empty', function () { expect(Object.keys(courses).length).toBeGreaterThan(0); }); it('test that the isEmpty is false', function () { expect(isEmpty).toBeFalsy(); }); it('test that the isEmptyCourses function returns same as above', function () { expect(cGPACalculator.isEmptyCourses(courses)).toBeFalsy(); }); it('test that the isObject function returns false for []', function () { expect(cGPACalculator.isObject(courses)).toBeTruthy(); expect(cGPACalculator.isObject(illFormatedCourses)).toBeTruthy(); expect(cGPACalculator.isObject(nonObjectCourses)).toBeFalsy(); }); }); describe('Testing that the json file contains a well formatted courses object', function () { describe('Testing that the isWellFormattedCourses Function Works Well', function () { var status1 = false; var status2 = false; var keys = ['code', 'title', 'units', 'semester']; beforeEach(function () { _.each(courses, function(department, key){ if (key === "Computer") { _.each(department, function(level, key){ if (key === "course2L") { _.each(level, function(courseList, key){ if (_.isEqual(Object.keys(courseList), keys)) { status1 = true; } }); } }); } }); _.each(illFormatedCourses, function(department, key){ if (key === "Computer") { _.each(department, function(level, key){ if (key === "course2L") { _.each(level, function(courseList, key){ if (_.isEqual(Object.keys(courseList), keys)) { status2 = true; } }); } }); } }); }); it('ill-formatted json file should not equal the above keys', function () { expect(status2).toBeFalsy(); }); it('well-formatted json file should equal the above keys', function () { expect(status1).toBeTruthy(); }); it('test that the isWellFormattedCourses function produces same result', function () { expect(cGPACalculator.isWellFormattedCourses(illFormatedCourses)).toBeFalsy(); expect(cGPACalculator.isWellFormattedCourses(courses)).toBeTruthy(); }); }); }); describe('Testing that the points computation function works well', function () { var unit = 4; var grade = 'B'; var gradePoint = 4; var point = unit * gradePoint; it('A gradepoint of 5 for a four unit course should give the student 20 points', function () { expect(point).toBe(16); }); it('function computePoint should produce same result', function () { expect(cGPACalculator.computePoint(unit, grade)).toBe(point); }); it('function evaluateGrade returns 5 for grade B', function () { expect(cGPACalculator.evaluateGrade(grade)).toBe(gradePoint); }); it('function computePoint must have called evaluateGrade', function () { cGPACalculator = new CGPACalculator(); // Before training - spies return undefined let spy = spyOn(cGPACalculator, "evaluateGrade"); // cGPACalculator.evaluateGrade = jasmine.createSpy("evaluateGrade spy"); var point = cGPACalculator.computePoint(unit, grade); expect(point).toBeNaN(); expect(cGPACalculator.evaluateGrade.calls.count()).toBe(1); // After training - calls original function, still spies execution spy.and.callThrough(); expect(cGPACalculator.computePoint(unit, grade)).toBe(16); expect(cGPACalculator.evaluateGrade.calls.count()).toBe(2); expect(cGPACalculator.evaluateGrade).toHaveBeenCalled(); }); }); describe('Testing that the total point computation function works well', function () { var allPoints = [20, 12, 15, 16, 8]; var totalPoints = 0; beforeEach(function () { for (var i = 0; i &amp;lt; allPoints.length; i++) { totalPoints = totalPoints + allPoints[i]; } }); it('Array of points like of [20, 12, 15, 16, 8] should return 71', function () { expect(totalPoints).toBe(71); }); it('function computeTotalPoint should produce same result', function () { expect(cGPACalculator.computeTotalPoint(allPoints)).toBe(71); }); }); describe('Testing that the cGPACalculator.startComputation function calls isWellFormattedCourses &amp;amp; isWellFormatted functions', function () { var surname = "Opara"; var firstName = "Febechukwu"; var middleName = "Chinonyerem"; var registrationNumber = "2007/NDM/14392"; var level = 2; var courseOfStudy = "Computer Science"; var semester = "Rain"; 127. var grades = ["A", "A", "A", "B", "C", "D", "E", "F"]; it('Testing that isWellFormatted &amp;amp; isWellFormattedCourses is called', function () { cGPACalculator = new CGPACalculator(); spyOn(cGPACalculator, "isWellFormattedCourses"); cGPACalculator.startComputation(surname, firstName, middleName, registrationNumber, level, courseOfStudy, semester, courses, grades); expect(cGPACalculator.isWellFormattedCourses).toHaveBeenCalled(); expect(cGPACalculator.isWellFormattedCourses).toHaveBeenCalledWith(courses); }); }); //Testing with Html Fixtures describe('Testing with HTML Fixtures', function () { var cGPACalculator = null; var fixture = null; beforeEach(function () { //inject the HTML Fixture for the tests fixture = '<div id="fixture">' + '<input id="surname" type="text">' + '<input id="firstName" type="text">' + '<input id="middleName" type="text">' + '<input id="registrationNumber" type="text">' + '<select id="level"><option value="1">100L</option>' + '<option value="2">200L</option>' + '<option value="3">300L</option>' + '<option value="4">400L</option>' + '<option value="5">500L</option></select>' + '<select id="courseOfStudy">' + '<option value="Computer">Computer Science</option>' + '<option value="Geology">Geology</option>' + '<option value="Mathematics">Mathematics</option>' + '<option value="Physics">Physics</option>' + '<option value="Mechanical Engineering">Mechanical Engineering</option></select>'+ '<select id="semester" onchange="cGPACalculator.enableLoadCourses()">' + '<option value="Rain">Rain</option>' + '<option value="Harmattan">Harmattan</option></select>' + '<button id="loadCourses" onclick="cGPACalculator.showInputTable()" disabled>'+ '<div id="table"><table><thead><th>Code</th>'+ '<th>Title</th><th>Units</th><th>Score</th><th>Grade</th>'+ '<th>Points</th></thead><tbody>'+ '<tr><td><input id="code1" class="code" readonly="readonly"></td>'+ '<td><input id="title1" class="title" readonly="readonly"></td>'+ '<td><input id="units1" class="units" readonly="readonly"></td>'+ '<td><input id="score1" class="score" onblur="cGPACalculator.showGrade1()"></td>'+ '<td><input id="grade1" class="grade" readonly="readonly"></td>'+ '<td><input id="points1" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code2" class="code" readonly="readonly"></td>'+ '<td><input id="title2" class="title" readonly="readonly"></td>'+ '<td><input id="units2" class="units" readonly="readonly"></td>'+ '<td><input id="score2" class="score" onblur="cGPACalculator.showGrade2()"></td>'+ '<td><input id="grade2" class="grade" readonly="readonly"></td>'+ '<td><input id="points2" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code3" class="code" readonly="readonly"></td>'+ '<td><input id="title3" class="title" readonly="readonly"></td>'+ '<td><input id="units3" class="units" readonly="readonly"></td>'+ '<td><input id="score3" class="score" onblur="cGPACalculator.showGrade3()"></td>'+ '<td><input id="grade3" class="grade" readonly="readonly"></td>'+ '<td><input id="points3" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code4" class="code" readonly="readonly"></td>'+ '<td><input id="title4" class="title" readonly="readonly"></td>'+ '<td><input id="units4" class="units" readonly="readonly"></td>'+ '<td><input id="score4" class="score" onblur="cGPACalculator.showGrade4()"></td>'+ '<td><input id="grade4" class="grade" readonly="readonly"></td>'+ '<td><input id="points4" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code5" class="code" readonly="readonly"></td>'+ '<td><input id="title5" class="title" readonly="readonly"></td>'+ '<td><input id="units5" class="units" readonly="readonly"></td>'+ '<td><input id="score5" class="score" onblur="cGPACalculator.showGrade5()"></td>'+ '<td><input id="grade5" class="grade" readonly="readonly"></td>'+ '<td><input id="points5" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code6" class="code" readonly="readonly"></td>'+ '<td><input id="title6" class="title" readonly="readonly"></td>'+ '<td><input id="units6" class="units" readonly="readonly"></td>'+ '<td><input id="score6" class="score" onblur="cGPACalculator.showGrade6()"></td>'+ '<td><input id="grade6" class="grade" readonly="readonly"></td>'+ '<td><input id="points6" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code7" class="code" readonly="readonly"></td>'+ '<td><input id="title7" class="title" readonly="readonly"></td>'+ '<td><input id="units7" class="units" readonly="readonly"></td>'+ '<td><input id="score7" class="score" onblur="cGPACalculator.showGrade7()"></td>'+ '<td><input id="grade7" class="grade" readonly="readonly"></td>'+ '<td><input id="points7" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code8" class="code" readonly="readonly"></td>'+ '<td><input id="title8" class="title" readonly="readonly"></td>'+ '<td><input id="units8" class="units" readonly="readonly"></td>'+ '<td><input id="score8" class="score" onblur="cGPACalculator.showGrade8()"></td>'+ '<td><input id="grade8" class="grade" readonly="readonly"></td>'+ '<td><input id="points8" class="points" readonly="readonly"></td></tr>'+ '<tr><td colspan="5">Total points Acquired</td>'+ '<td colspan="5"><input class="total" id="total" readonly="readonly"></td></tr>'+ '<tr><td colspan="5">CGPA</td>'+ '<td colspan="5"><input class="cgpa" id="cgpa" readonly="readonly"></td></tr>'+ '</tbody></table></div>'; setFixtures(fixture); $('#table').hide(); cGPACalculator = newCGPACalculator(); }); describe("Test that all tags that make function calls to be sure that have their props set to it", function () { it("Test that semester select tag has onChange property", function () { expect($('#semester')).toBeInDOM(); expect($('#semester')).toHaveAttr("onchange", "cGPACalculator.enableLoadCourses()"); }); it("Test that loadCourses button tag has onclick property", function () { expect($('#loadCourses')).toHaveAttr("onclick", "cGPACalculator.showInputTable()"); }); it("Test that scores input tag has onblur property", function () { expect($('#score1')).toHaveAttr("onblur","cGPACalculator.showGrade1()"); expect($('#score2')).toHaveAttr("onblur","cGPACalculator.showGrade2()"); expect($('#score3')).toHaveAttr("onblur","cGPACalculator.showGrade3()"); expect($('#score4')).toHaveAttr("onblur","cGPACalculator.showGrade4()"); expect($('#score5')).toHaveAttr("onblur","cGPACalculator.showGrade5()"); expect($('#score6')).toHaveAttr("onblur","cGPACalculator.showGrade6()"); expect($('#score7')).toHaveAttr("onblur","cGPACalculator.showGrade7()"); expect($('#score8')).toHaveAttr("onblur","cGPACalculator.showGrade8()"); }); }); describe("Test that button loadCourses is enabled when all fields are filled", function () { beforeEach(function () { $('#surname').val("Nwosu"); $('#firstName').val("Angela"); $('#middleName').val("Maureen"); $('#registrationNumber').val("2007/KDM/8976"); $('#level option[value=2]').prop('selected', 'selected').change(); $('#courseOfStudy option[value=Computer]').prop('selected', 'selected').change(); $('#semester option[value=Rain]').prop('selected', 'selected').change(); }); it("Test that the loadCourses button is enabled once all data is provided", function () { expect($('#loadCourses')).not.toBeDisabled(); }); }); describe("Test loadCourses button click", function () { beforeEach(function () { spyEvent = spyOnEvent('#loadCourses', 'click'); $('#loadCourses').trigger("click"); }); it("should invoke the loadCourses click event.", function () { expect('click').toHaveBeenTriggeredOn('#loadCourses'); expect(spyEvent).toHaveBeenTriggered(); }); it("should show the table once the button is clicked.", function () { expect($('#table')).not.toBeHidden(); }); it("should have all tags with css class as code, title, units, grade and points to be readonly.", function () { expect($('.code')).toHaveAttr("readonly"); expect($('.title')).toHaveProp("readonly"); expect($('.units')).toHaveProp("readonly"); expect($('.grade')).toHaveProp("readonly"); expect($('.points')).toHaveProp("readonly"); }); }); describe("Test showCourseDetails function", function () { var cGPACalculator = null; it("Test that showCourseDetails called isEmptyCourses function", function () { cGPACalculator = new CGPACalculator(); spyOn(cGPACalculator, "isEmptyCourses"); cGPACalculator.showCourseDetails(courses); expect(cGPACalculator.isEmptyCourses.calls.count()).toBe(1); expect(cGPACalculator.isEmptyCourses).toHaveBeenCalled(); expect(cGPACalculator.isEmptyCourses).toHaveBeenCalledWith(courses); }); it("Test that showCourseDetails called isWellFormattedCourses function", function () { cGPACalculator = new CGPACalculator(); spyOn(cGPACalculator, "isWellFormattedCourses"); cGPACalculator.showCourseDetails(courses); expect(cGPACalculator.isWellFormattedCourses.calls.count()).toBe(1); expect(cGPACalculator.isWellFormattedCourses).toHaveBeenCalled(); expect(cGPACalculator.isWellFormattedCourses).toHaveBeenCalledWith(courses); }); }); describe("Test that course details are populated on table", function () { beforeEach(function () { cGPACalculator = new CGPACalculator(); cGPACalculator.showCourseDetails(courses); }); it("should have all course codes not empty.", function () { expect($('#code1').val).not.toBe(""); expect($('#code2').val).not.toBe(""); expect($('#code3').val).not.toBe(""); expect($('#code4').val).not.toBe(""); expect($('#code5').val).not.toBe(""); expect($('#code6').val).not.toBe(""); expect($('#code7').val).not.toBe(""); expect($('#code8').val).not.toBe(""); }); it("should have all course titles not empty.", function () { expect($('#title1').val).not.toBe(""); expect($('#title2').val).not.toBe(""); expect($('#title3').val).not.toBe(""); expect($('#title4').val).not.toBe(""); expect($('#title5').val).not.toBe(""); expect($('#title6').val).not.toBe(""); expect($('#title7').val).not.toBe(""); expect($('#title8').val).not.toBe(""); }); it("should have all course units not empty.", function () { expect($('#units1').val).not.toBe(""); expect($('#units2').val).not.toBe(""); expect($('#units3').val).not.toBe(""); expect($('#units4').val).not.toBe(""); expect($('#units5').val).not.toBe(""); expect($('#units6').val).not.toBe(""); expect($('#units7').val).not.toBe(""); expect($('#units8').val).not.toBe(""); }); it("should have all course grades empty.", function () { expect($('#grade1').val()).toBe(""); expect($('#grade2').val()).toBe(""); expect($('#grade3').val()).toBe(""); expect($('#grade4').val()).toBe(""); expect($('#grade5').val()).toBe(""); expect($('#grade6').val()).toBe(""); expect($('#grade7').val()).toBe(""); expect($('#grade8').val()).toBe(""); }); it("should have all course points empty.", function () { expect($('#points1').val()).toBe(""); expect($('#points2').val()).toBe(""); expect($('#points3').val()).toBe(""); expect($('#points4').val()).toBe(""); expect($('#points5').val()).toBe(""); expect($('#points6').val()).toBe(""); expect($('#points7').val()).toBe(""); expect($('#points8').val()).toBe(""); }); }); describe("Test Entering Score for the 8 courses", function () { var cGPACalculator = null; beforeEach(function () { $('#score1').val("70"); $('#score1').blur(); $('#score2').val("80"); $('#score2').blur(); $('#score3').val("90"); $('#score3').blur(); $('#score4').val("60"); $('#score4').blur(); $('#score5').val("50"); $('#score5').blur(); $('#score6').val("40"); $('#score6').blur(); $('#score7').val("20"); $('#score7').blur(); $('#score8').val("10"); $('#score8').blur(); }); it("should populate input tags for grade and points with CORRECT values.", function () { expect($('#grade1').val()).toBe("A"); expect($('#grade2').val()).toBe("A"); expect($('#grade3').val()).toBe("A"); expect($('#grade4').val()).toBe("B"); expect($('#grade5').val()).toBe("C"); expect($('#grade6').val()).toBe("D"); expect($('#grade7').val()).toBe("E"); expect($('#grade8').val()).toBe("F"); expect($('#points1').val()).toBe('5'); expect($('#points2').val()).toBe('5'); expect($('#points3').val()).toBe('5'); expect($('#points4').val()).toBe('4'); expect($('#points5').val()).toBe('3'); expect($('#points6').val()).toBe('2'); expect($('#points7').val()).toBe('1'); expect($('#points8').val()).toBe('0'); }); it("should call evaluateGrade, computeTotalPoint, computePoint and computeCGPA and function whenever showGrade is called.", function () { cGPACalculator = new CGPACalculator(); let spy = spyOn(cGPACalculator, "showPoint"); cGPACalculator.showGrade1(); expect(cGPACalculator.showPoint).toHaveBeenCalled(); expect(cGPACalculator.showPoint).toHaveBeenCalledWith(5, "#points1"); expect(cGPACalculator.showPoint.calls.count()).toBe(1); spy = spyOn(cGPACalculator, "evaluateGrade"); cGPACalculator.showGrade1(); expect(cGPACalculator.evaluateGrade).toHaveBeenCalled(); expect(cGPACalculator.evaluateGrade).toHaveBeenCalledWith("A"); spy = spyOn(cGPACalculator, "computePoint"); cGPACalculator.showGrade1(); expect(cGPACalculator.computePoint).toHaveBeenCalled(); cGPACalculator.showGrade2(); cGPACalculator.showGrade3(); cGPACalculator.showGrade4(); cGPACalculator.showGrade5(); cGPACalculator.showGrade6(); cGPACalculator.showGrade7(); cGPACalculator.showGrade8(); spy = spyOn(cGPACalculator, "computeTotalPoint"); cGPACalculator.showGrade8(); expect(cGPACalculator.computeTotalPoint).toHaveBeenCalled(); spy = spyOn(cGPACalculator, "computeCGPA"); cGPACalculator.showGrade8(); expect(cGPACalculator.computeCGPA).toHaveBeenCalled(); }); }); });
In case you do not understand the tests written for the fixtures, make use of the documentation for jquery and jasmine-jquery. I believe you will understand the code after referring to this document.
Now the specRunner would say ‘no specs found’, and when you inspect the web page and open the console tab, you can see the error saying:
“Uncaught ReferenceError: CGPACalculator is not defined
at Object.4…/json/courses.json (bundleSpec.js:75)
at s (bundleSpec.js:1)
at e (bundleSpec.js:1)
at bundleSpec.js:1″.
To solve this problem we need to define the CGPACalculator class. Hence, just create a dummy one using the code below.
/** * CGPA Calculator class * @class{CGPACalculator} */ classCGPACalculator { }
Now, the tests are running but are failing as shown in the diagram below.
This leads us to the next steps i.e. writing the needed functions for the test to pass (We learned TDD in our previous tutorials) and adding scripts that our code will make use of, like the node-based package underscore.
All Tests Running On SpecRunner Are Failing.
Step IV: Create content for the ‘courses.json’ file as shown below-
{ "Computer": { "course2L": { "1": { "code": "CSC201", "title": "Introduction to Java Programming", "units": "3", "semester": "Rain" }, "2": { "code": "CSC202", "title": "Introduction to Systems Programming", "units": "4", "semester":"Rain" }, "3": { "code": "MTH201", "title": "Mathematical Methods 1", "units": "3", "semester": "Rain" }, "4": { "code": "MTH203", "title": "Operations Research 1", "units": "3", "semester": "Rain" }, "5": { "code": "MTH204", "title": "Real Analysis 1", "units": "3", "semester": "Rain" }, "6": { "code": "CSC205", "title": "Introduction to Compiler Construction", "units": "4", "semester": "Rain" }, "7": { "code": "CSC206", "title": "Survey of Programming Languages", "units": "3", "semester": "Rain" }, "8": { "code": "CSC207", "title": "Databases 1", "units": "3", "semester": "Rain" }, "9": { "code": "MTH207", "title": "Numerical Analysis", "units": "3", "semester": "Harmattan" } } } }
Course2L above means 200 level courses under the department computer. So, at your own discretion, you can similarly populate values for 100L, 300L, and 400L and also create data for other departments like physics, geology, etc.
For our purpose, we will use just that as given above.
Step V: Now before we proceed, there are scripts that we need to add to the specRunner.html so that the tests that make use of Jasmine-jquery matchers can run, that is the jquery and jasmine-jquery so, our specRunner.html should be modified to be:
<scriptsrc="lib/jasmine-2.5.2/jasmine.js"></script> <scriptsrc="lib/jasmine-2.5.2/jasmine-html.js"></script> <scriptsrc="lib/jasmine-2.5.2/boot.js"></script> <scriptsrc="node_modules/jquery/dist/jquery.js"></script> <scriptsrc="node_modules/jasmine-jquery/lib/jasmine-jquery.js"></script> <!-- include source files here... --> <!--<script src="json/course.json"></script>--> <scriptsrc="src/CGPACalculator.js"></script> <!-- include spec files here... --> <scriptsrc="bundle/bundleSpec.js"></script> </head> <body> </body> </html>
We need the underscore package to loop through our JSON courses object and retrieve details. We can install it using npm or use its CDN link.
To install, use npm install underscore –save or add it as a script tag before the spec and source files in the specRunner via the CDN link.
We would use the CDN link to avoid the use of the ‘require’ statement in the source file, which will involve bundling it. See line 15 of the code above.
#4) Writing The Required Functions Mentioned In The Test
Now, for the tests to pass, we will write the functions that are required in the class defined above. This is shown below-
/** * CGPA Calculator class * @class {CGPACalculator} */ class CGPACalculator { /** * Create a CGPACalculator. * @constructor */ constructor() { this.surname = ""; this.firstName = ""; this.middleName = ""; this.registrationNumber = ""; this.level = ""; this.courseOfStudy = ""; this.semester = ""; this.cgpa = 0; this.totalPoints = 0; this.points = []; this.units = []; this.totalUnits = 0; this.gradeSystem = {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1, "F": 0}; } /** * isEmptyCourses * * Checks that the courses * object isn't empty. * * @param {object} courses * @returns {boolean} returns Boolean */ isEmptyCourses(courses) { var status = true; if (this.isObject(courses)) { if (Object.keys(courses).length &amp;gt; 0) { status = false; } } return status; } /** * isObject * * Checks that the courses * variable is an object. * * @param {object} courses * @returns {boolean} returns Boolean */ isObject(courses) { var status = false; if (Object.prototype.toString.call(courses) === '[object Object]') { status = true; } return status; } /** * isWellFormattedCourses * * Checks that the courses variable * is formatted as expected. * * @param {object} courses * @returns {boolean} returns Boolean */ isWellFormattedCourses(courses) { var status = false; var keys = ['code', 'title', 'units', 'semester']; _.each(courses, function (department, key) { if (key === "Computer") { _.each(department, function (level, key) { if (key === "course2L") { _.each(level, function (courseList, key) { if (_.isEqual(Object.keys(courseList), keys)) { status = true; } }); } }); } }); return status; } /** * computePoint * * computes the point acquired * for a particular grade * * @param {number} unit * @param {String} grade * @returns {number} returns point computed */ computePoint(unit, grade) { var gradeValue = this.evaluateGrade(grade); var point = gradeValue * unit; return point; } /** * evaluateGrade * * finds point equivalence of grade * * @param {String} grade * @returns {number} returns point equivalence of grade */ evaluateGrade(grade) { return this.gradeSystem[grade]; } /** * computeTotalPoint * * calculates the total accumulated * points from all courses * * @param {Array} points * @returns {number} returns total of points */ computeTotalPoint(points) { for (var i = 0; i &amp;lt; points.length; i++) { this.totalPoints = this.totalPoints + points[i]; } return this.totalPoints; } /** * startComputation * * kicks off the computation * of the cgpa * * @param {String} surname * @param {String} firstName * @param {String} middleName * @param {String} registrationNumber * @param {String} level * @param {String} courseOfStudy * @param {String} semester * @param {Object} courses * @returns {void} returns nothing */ startComputation(surname, firstName, middleName, registrationNumber, level, courseOfStudy, semester, courses, grades) { this.surname = surname; this.firstName = firstName; this.middleName = middleName; this.registrationNumber = registrationNumber; this.level = level; this.courseOfStudy = courseOfStudy; this.semester = semester; var isEmpty = this.isEmptyCourses(courses); var points = []; if (isEmpty === false) { var isWellFormatted = this.isWellFormattedCourses(courses); var i = 0; if (isWellFormatted) { _.each(courses, function (department, key) { if (key === courseOfStudy) { _.each(department, function (leve, key) { if (key === level) { _.each(leve, function (courseList, key) { points[i] = this.computePoint(courseList["units"], grades[i]); i = i + 1; }); } }); } }); } this.totalPoints = this.computeTotalPoint(points); } } /** * enableLoadCourses * * enables the loading * of courses * * @param {void} * @returns {void} returns nothing */ enableLoadCourses() { $('#loadCourses').removeAttr('disabled'); } /** * showCourseDetails * * shows the course * details * * @param {courses} all courses object * @returns {void} returns nothing */ showCourseDetails(courses) { var dept = $('#courseOfStudy').val(); var studentLevel = $('#level').val(); var semester = $('#semester').val(); var isEmpty = this.isEmptyCourses(courses); if (isEmpty === false) { var isWellFormatted = this.isWellFormattedCourses(courses); var i = 1; if (isWellFormatted) { _.each(courses, function (department, key) { if (key === dept) { _.each(department, function (leve, key) { if (key === studentLevel) { _.each(leve, function (courseList, key) { if (courseList["semester"] === semester) { $('#code'+i).val(courseList["code"]); $('#title'+i).val(courseList["title"]); $('#units'+i).val(courseList["units"]); i = i + 1; } }); } }); } }); } } } /** * showInputTable * * shows the input * table * * @param {void} * @returns {void} returns nothing */ showInputTable() { $('#table').show(); } /** * evaluateScore * * evaluate Score * * @param {score} number * @returns {number} returns grade */ evaluateScore(score) { var grade = ""; if (score &amp;gt;= 70 &amp;amp;&amp;amp; score &amp;lt;= 100) { grade = 'A'; } else if (score &amp;gt;= 60 &amp;amp;&amp;amp; score &amp;lt;= 69) { grade = 'B'; } else if (score &amp;gt;= 50 &amp;amp;&amp;amp; score &amp;lt;= 59) { grade = 'C'; } else if (score &amp;gt;= 30 &amp;amp;&amp;amp; score &amp;lt;= 49) { grade = 'D'; } else if (score &amp;gt;= 20 &amp;amp;&amp;amp; score &amp;lt;= 29) { grade = 'E'; } else { grade = 'F'; } return grade; } /** * showGrade1 - showGrade8 * * displays the evaluated grade * * @param {void} * @returns {void} */ showGrade1() { var grade1 = document.getElementById('score1').value; var unit = document.getElementById('units1').value; var grade = this.evaluateScore(grade1); var poin = this.computePoint(unit, grade1); this.points.push(poin); this.units.push(unit); $('#grade1').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points1'); } showGrade2() { var grade2 = document.getElementById('score2').value; var unit = document.getElementById('units2').value; var grade = this.evaluateScore(grade2); var poin = this.computePoint(unit, grade2); this.points.push(poin); this.units.push(unit); $('#grade2').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points2'); } showGrade3() { var grade3 = document.getElementById('score3').value; var unit = document.getElementById('units3').value; var grade = this.evaluateScore(grade3); var poin = this.computePoint(unit, grade3); this.points.push(poin); this.units.push(unit); $('#grade3').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points3'); } showGrade4() { var grade4 = document.getElementById('score4').value; var unit = document.getElementById('units4').value; var grade = this.evaluateScore(grade4); var poin = this.computePoint(unit, grade4); this.points.push(poin); this.units.push(unit); $('#grade4').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points4'); } showGrade5() { var grade5 = document.getElementById('score5').value; var unit = document.getElementById('units5').value; var grade = this.evaluateScore(grade5); var poin = this.computePoint(unit, grade5); this.points.push(poin); this.units.push(unit); $('#grade5').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points5'); } showGrade6() { var grade6 = document.getElementById('score6').value; var unit = document.getElementById('units6').value; var grade = this.evaluateScore(grade6); var poin = this.computePoint(unit, grade6); this.points.push(poin); this.units.push(unit); $('#grade6').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points6'); } showGrade7() { var grade7 = document.getElementById('score7').value; var unit = document.getElementById('units7').value; var grade = this.evaluateScore(grade7); var poin = this.computePoint(unit, grade7); this.points.push(poin); this.units.push(unit); $('#grade7').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points7'); } showGrade8() { var grade8 = document.getElementById('score8').value; var unit = document.getElementById('units8').value; var grade = this.evaluateScore(grade8); var poin = this.computePoint(unit, grade8); this.points.push(poin); this.units.push(unit); $('#grade8').val(grade); var point = this.evaluateGrade(grade); this.showPoint(point, '#points8'); this.computeTotalPoint(this.points); this.computeCGPA(); } /** * showPoint * * displays the associated * points of the grade * * @param {void} * @returns {void} */ showPoint(point, elementId) { this.points.push(point); $(elementId).val(point); } /** * computeTotalUnits * * computes the * total units * * @param {void} * @returns {void} */ computeTotalUnits() { for (var i=0; i&amp;lt;this.units.length; i++) { this.totalUnits = this.totalUnits + this.units[i]; } } /** * computeCGPA * * computes the * cgpa * * @param {void} * @returns {void} */ computeCGPA() { this.totalUnits = this.computeTotalUnits(); this.cgpa = this.totalPoints / this.totalUnits; this.showCGPA(); this.showTotalPoints(); } /** * showCGPA * * displays the * cgpa * * @param {void} * @returns {void} */ showCGPA() { $('#cgpa').val(this.cgpa); } /** * showTotalPoints * * displays the * total points * * @param {void} * @returns {void} */ showTotalPoints() { $('#total').val(this.totalPoints); } }
This makes the tests run and pass. In case I have omitted to expose certain steps, see the GitHub repo using the link given at the end of this tutorial.
Implementing The Project Using Karma And Jasmine
This is the second way in which Jasmine can be used for front-end unit testing. We would start off by seeing our complete Karma configuration file.
#1) Modifying The karma.conf.js file To Suit The New Project Structure
To enable bundling in your karma.conf.js, you change the preprocessor configuration to:
preprocessors: { 'spec/*Spec.js': [ 'browserify' ] },
You must first install karma-browserify. To do this, run ‘npm install karma-browserify –save-dev’on the command line.
Note that Karma-browserify needs the browserify package to work. We are not installing that now as we have already installed it in the gulp file to work.
Next, add this extra configuration for the browserify plugin:
// add additional browserify configuration properties here // such as transform and/or debug=true to generate source maps browserify: { debug:true, transform: [ 'brfs' ], configure:function(bundle) { bundle.on('prebundle', function() { bundle.external('CGPASpec'); }); } },
Finally, add browserify as a framework that karma will use as shown below-
// frameworks to use // available frameworks: <a href="https://npmjs.org/browse/keyword/karma-adapter">https://npmjs.org/browse/keyword/karma-adapter</a> frameworks: ['jasmine','browserify'],
What Does The Browserify Configuration Options Debug, Transform, And Configure Mean?
The debug option if set to true enables source maps for you. These source maps allow debugging of files separately. Now, the question arises that what are source maps?
Source maps are a mapping of a minified and bundled file to its original source.
Why Do You Need This?
They enable debugging when you have a source map in place and a source map querying tool. When you have a bug in your minified file and if you click on the line and use the query tool, the map tells you the line in the original file that corresponds to that error.
See here for a better understanding of source maps.
The transform option accepts an array of packages. The transform option changes the files on which the package (browserify) is added, as a preprocessor. In our case, we choose, brfs.
What Is brfs?
brfs is a node package that facilitates the reading of files and the in-lining of its contents into our bundles. It was created to be used with browserify but it can still be used independently for other purposes. See here for details.
In our case, passing our test to browserify that is configured to make use of the transform package brfs simply means that during the bundling of files, when the require statement for the JSON file is encountered, the file content is read and inlined into our source code before bundling is continued by browserify.
Other transformations that can be performed include transforming codes written in es6 to es5 that is achieved using babel, transforming jsx react files to plain JavaScript that is achieved using reactify, etc.
The configure option is used to add additional configuration to the bundling process. It accepts a function that in turn accepts an instance of browserify as an argument. Just before the bundling of files is finished by browserify, the pre-bundle event is emitted and this is used to set up externals.
What Are Externals?
Externals are prior inexistent files or resources created after a transformation is being carried out by any transformation package.
#2) Writing A Separate Test File For Karma
The test spec and the source code above would work fine for the specRunner.html but wouldn’t work with the karma test runner due to the below-mentioned reasons.
#1) Our CGPA Calculator class is written in the es6 style and the Karma test runner is going to complain about the keyword class.
Why doesn’t the specRunner way of running the tests complain about the keyword class but karma does?
This is because that way of coding is supported by most browsers, and the specRunner.html is loaded into the browser which executes the test. However, in this case, karma spins up the browsers to run the test and there is the headless browser phantomJS, hence the keyword class may not be understood.
To solve this problem, we need to use babel to transpile the class from the es6 form to es5. This would make the learning session a bit longer. Hence, we would create another version of the class following the es5 style.
#2) The underscore class that we used in the application would not be found by karma when it tries to run the test and encounter the _ prefix before the functions that the module provides for us.
To solve that problem, we edit the karma.conf.js file to have the file configuration look like:
// Karma configuration // Generated on Thu Mar 23 2017 15:48:11 GMT+0100 (W. Central Africa Standard Time) module.exports = function(config) { config.set({ // base path that will be used to resolve all patterns (eg. files, exclude) basePath: '', // frameworks to use // available frameworks: https://npmjs.org/browse/keyword/karma-adapter frameworks: ['jasmine','browserify'], // list of files / patterns to load in the browser files: [ 'node_modules/underscore/underscore.js', 'src/js/*.1.js', 'spec/*Spec2.js', 'node_modules/jquery/dist/jquery.js', 'node_modules/jasmine-jquery/lib/jasmine-jquery.js' ], // list of files to exclude exclude: [ ], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { 'spec/*Spec2.js': [ 'browserify' ] }, // add additional browserify configuration properties here // such as transform and/or debug=true to generate source maps browserify: { debug: true, transform: ['brfs'], configure: function(bundle) { bundle.on('prebundle', function() { bundle.external('CGPASpec'); }); } }, // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter reporters: ['progress'], // web server port port: 9876, // enable / disable colors in the output (reporters and logs) colors: true, // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG logLevel: config.LOG_ERROR, // enable / disable watching file and executing tests whenever any file changes autoWatch: true, // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher // browsers: ['Chrome', 'Firefox', 'PhantomJS'], browsers: ['Chrome', 'PhantomJS'], // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits singleRun: false, // Concurrency level // how many browser should be started simultaneous concurrency: Infinity }) }
Did you notice that the spec file changed to *Spec2.js? This is because we are going to create another version of our spec file which will solve the third problem below.
#3) Karma is also going to throw some errors concerning the cGPACalculator variable as shown in the below figure.
Remember in the fixture part of the test, we are calling some functions in our class when events like ‘onblur’ e.t.c are triggered, and the object cGPACalculator has not been defined at any place in the HTML fixture. Hence, to solve the problem, the fixture part of our spec changes to this:
//Testing with Html Fixtures describe('Testing with HTML Fixtures', function () { varcGPACalculator = null; varfixture = null; beforeEach(function () { //inject the HTML Fixture for the tests fixture = '<script> var cGPACalculator = new CGPACalculator(); </script>'+ '<div id="fixture">' + '<input id="surname" type="text">' + '<input id="firstName" type="text">' + '<input id="middleName" type="text">' + '<input id="registrationNumber" type="text">' + '<select id="level"><option value="1">100L</option>' + '<option value="2">200L</option>' + '<option value="3">300L</option>' + '<option value="4">400L</option>' + '<option value="5">500L</option></select>' + '<select id="courseOfStudy">' + '<option value="Computer">Computer Science</option>' + '<option value="Geology">Geology</option>' + '<option value="Mathematics">Mathematics</option>' + '<option value="Physics">Physics</option>' + '<option value="Mechanical Engineering">Mechanical Engineering</option></select>'+ '<select id="semester" onchange="cGPACalculator.enableLoadCourses()">' + '<option value="Rain">Rain</option>' + '<option value="Harmattan">Harmattan</option></select>' + '<button id="loadCourses" onclick="cGPACalculator.showInputTable()" disabled>'+ '<div id="table"><table><thead><th>Code</th>'+ '<th>Title</th><th>Units</th><th>Score</th><th>Grade</th>'+ '<th>Points</th></thead><tbody>'+ '<tr><td><input id="code1" class="code" readonly="readonly"></td>'+ '<td><input id="title1" class="title" readonly="readonly"></td>'+ '<td><input id="units1" class="units" readonly="readonly"></td>'+ '<td><input id="score1" class="score" onblur="cGPACalculator.showGrade1()"></td>'+ '<td><input id="grade1" class="grade" readonly="readonly"></td>'+ '<td><input id="points1" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code2" class="code" readonly="readonly"></td>'+ '<td><input id="title2" class="title" readonly="readonly"></td>'+ '<td><input id="units2" class="units" readonly="readonly"></td>'+ '<td><input id="score2" class="score" onblur="cGPACalculator.showGrade2()"></td>'+ '<td><input id="grade2" class="grade" readonly="readonly"></td>'+ '<td><input id="points2" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code3" class="code" readonly="readonly"></td>'+ '<td><input id="title3" class="title" readonly="readonly"></td>'+ '<td><input id="units3" class="units" readonly="readonly"></td>'+ '<td><input id="score3" class="score" onblur="cGPACalculator.showGrade3()"></td>'+ '<td><input id="grade3" class="grade" readonly="readonly"></td>'+ '<td><input id="points3" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code4" class="code" readonly="readonly"></td>'+ '<td><input id="title4" class="title" readonly="readonly"></td>'+ '<td><input id="units4" class="units" readonly="readonly"></td>'+ '<td><input id="score4" class="score" onblur="cGPACalculator.showGrade4()"></td>'+ '<td><input id="grade4" class="grade" readonly="readonly"></td>'+ '<td><input id="points4" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code5" class="code" readonly="readonly"></td>'+ '<td><input id="title5" class="title" readonly="readonly"></td>'+ '<td><input id="units5" class="units" readonly="readonly"></td>'+ '<td><input id="score5" class="score" onblur="cGPACalculator.showGrade5()"></td>'+ '<td><input id="grade5" class="grade" readonly="readonly"></td>'+ '<td><input id="points5" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code6" class="code" readonly="readonly"></td>'+ '<td><input id="title6" class="title" readonly="readonly"></td>'+ '<td><input id="units6" class="units" readonly="readonly"></td>'+ '<td><input id="score6" class="score" onblur="cGPACalculator.showGrade6()"></td>'+ '<td><input id="grade6" class="grade" readonly="readonly"></td>'+ '<td><input id="points6" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code7" class="code" readonly="readonly"></td>'+ '<td><input id="title7" class="title" readonly="readonly"></td>'+ '<td><input id="units7" class="units" readonly="readonly"></td>'+ '<td><input id="score7" class="score" onblur="cGPACalculator.showGrade7()"></td>'+ '<td><input id="grade7" class="grade" readonly="readonly"></td>'+ '<td><input id="points7" class="points" readonly="readonly"></td></tr>'+ '<tr><td><input id="code8" class="code" readonly="readonly"></td>'+ '<td><input id="title8" class="title" readonly="readonly"></td>'+ '<td><input id="units8" class="units" readonly="readonly"></td>'+ '<td><input id="score8" class="score" onblur="cGPACalculator.showGrade8()"></td>'+ '<td><input id="grade8" class="grade" readonly="readonly"></td>'+ '<td><input id="points8" class="points" readonly="readonly"></td></tr>'+ '<tr><td colspan="5">Total points Acquired</td>'+ '<td colspan="5"><input class="total" id="total" readonly="readonly"></td></tr>'+ '<tr><td colspan="5">CGPA</td>'+ '<td colspan="5"><input class="cgpa" id="cgpa" readonly="readonly"></td></tr>'+ '</tbody></table></div>'; setFixtures(fixture); $('#table').hide(); cGPACalculator = newCGPACalculator(); });
Karma Error – ‘can’t find variable cGPPACalculator’
Did you notice an extra line of code on line 142? That line of code creates an object of our class so that we can be able to call the functions that are meant to be called when events like ‘onblur’ e.t.c are triggered during the tests.
We would consider the es6 style of coding and all transpiling issues when we look at the front-end unit testing of AngularJS web applications in our subsequent tutorials.
#3) Re-implement Functions Using Prototype Pattern Of Defining A Class
There are three ways to define a JavaScript class as shown below.
- Using a function.
- Using Object Literals
- Singleton using a function.
For more details see the tutorials here and here.
We would adopt the ‘using a function’ method where we add our methods to the prototype, see below for the class in es5 form:
/** * Create a CGPACalculator. * @constructor */ function CGPACalculator() { this.surname = ""; this.firstName = ""; this.middleName = ""; this.registrationNumber = ""; this.level = ""; this.courseOfStudy = ""; this.semester = ""; this.cgpa = 0; this.totalPoints = 0; this.points = []; this.units = []; this.totalUnits = 0; this.gradeSystem = {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1, "F": 0}; } /** * isEmptyCourses * * Checks that the courses * object isn't empty. * * @param {object} courses * @returns {boolean} returns Boolean */ CGPACalculator.prototype.isEmptyCourses = function(courses) { var status = true; if (this.isObject(courses)) { if (Object.keys(courses).length> 0) { status = false; } } return status; } /** * isObject * * Checks that the courses * variable is an object. * * @param {object} courses * @returns {boolean} returns Boolean */ CGPACalculator.prototype.isObject = function(courses) { var status = false; if (Object.prototype.toString.call(courses) === '[object Object]') { status = true; } return status; } /** * isWellFormattedCourses * * Checks that the courses variable * is formatted as expected. * * @param {object} courses * @returns {boolean} returns Boolean */ CGPACalculator.prototype.isWellFormattedCourses = function(courses) { var status = false; var keys = ['code', 'title', 'units', 'semester']; _.each(courses, function (department, key) { if (key === "Computer") { _.each(department, function (level, key) { if (key === "course2L") { _.each(level, function (courseList, key) { if (_.isEqual(Object.keys(courseList), keys)) { status = true; } }); } }); } }); return status; } /** * computePoint * * computes the point acquired * for a particular grade * * @param {number} unit * @param {String} grade * @returns {number} returns point computed */ CGPACalculator.prototype.computePoint = function(unit, grade) { var gradeValue = this.evaluateGrade(grade); var point = gradeValue * unit; return point; } /** * evaluateGrade * * finds point equivalence of grade * * @param {String} grade * @returns {number} returns point equivalence of grade */ CGPACalculator.prototype.evaluateGrade = function(grade) { return this.gradeSystem[grade]; } /** * computeTotalPoint * * calculates the total accumulated * points from all courses * * @param {Array} points * @returns {number} returns total of points */ CGPACalculator.prototype.computeTotalPoint = function(points) { for (var i = 0; i &amp;lt; points.length; i++) { this.totalPoints = this.totalPoints + points[i]; } return this.totalPoints; } /** * startComputation * * kicks off the computation * of the cgpa * * @param {String} surname * @param {String} firstName * @param {String} middleName * @param {String} registrationNumber * @param {String} level * @param {String} courseOfStudy * @param {String} semester * @param {Object} courses * @returns {void} returns nothing */ CGPACalculator.prototype.startComputation = function(surname, firstName, middleName, registrationNumber, level, courseOfStudy, semester, courses, grades) { this.surname = surname; this.firstName = firstName; this.middleName = middleName; this.registrationNumber = registrationNumber; this.level = level; this.courseOfStudy = courseOfStudy; this.semester = semester; var isEmpty = this.isEmptyCourses(courses); var points = []; if (isEmpty === false) { var isWellFormatted = this.isWellFormattedCourses(courses); var i = 0; if (isWellFormatted) { _.each(courses, function (department, key) { if (key === courseOfStudy) { _.each(department, function (leve, key) { if (key === level) { _.each(leve, function (courseList, key) { points[i] = this.computePoint(courseList["units"], grades[i]); i = i + 1; }); } }); } }); } this.totalPoints = this.computeTotalPoint(points); } } /** * enableLoadCourses * * enables the loading * of courses * * @param {void} * @returns {void} returns nothing */ CGPACalculator.prototype.enableLoadCourses = function() { $('#loadCourses').removeAttr('disabled'); } /** * showCourseDetails * * shows the course * details * * @param {courses} all courses object * @returns {void} returns nothing */ CGPACalculator.prototype.showCourseDetails = function(courses) { var dept = $('#courseOfStudy').val(); var studentLevel = $('#level').val(); var semester = $('#semester').val(); if (courses) { var isEmpty = this.isEmptyCourses(courses); if (isEmpty === false) { var isWellFormatted = this.isWellFormattedCourses(courses); var i = 1; if (isWellFormatted) { _.each(courses, function (department, key) { if (key === dept) { _.each(department, function (leve, key) { if (key === studentLevel) { _.each(leve, function (courseList, key) { if (courseList["semester"] === semester) { $('#code'+i).val(courseList["code"]); $('#title'+i).val(courseList["title"]); $('#units'+i).val(courseList["units"]); i = i + 1; } }); } }); } }); } } } else { this.readJSONFile("../../json/courses.json", function(text){ var data = JSON.parse(text); var i = 1; _.each(data, function (department, key) { if (key === dept) { _.each(department, function (leve, key) { if (key === studentLevel) { _.each(leve, function (courseList, key) { if (courseList["semester"] === semester) { $('#code'+i).val(courseList["code"]); $('#title'+i).val(courseList["title"]); $('#units'+i).val(courseList["units"]); i = i + 1; } }); } }); } }); }); } } /** * showInputTable * * shows the input * table * * @param {callback} function * @returns {void} returns nothing */ CGPACalculator.prototype.showInputTable = function() { $('#table').show(); this.showCourseDetails(null); } /** * evaluateScore * * evaluate Score * * @param {score} number * @returns {number} returns grade */ CGPACalculator.prototype.evaluateScore = function(score) { var grade = ""; if (score >= 70 && score<= 100) { grade = 'A'; } else if (score >= 60 && score <= 69) { grade = 'B'; } else if (score >= 50 && score <= 59) { grade = 'C'; } else if (score >= 30 && score <= 49) { grade = 'D'; } else if (score >= 20 && score <= 29) { grade = 'E'; } else { grade = 'F'; } return grade; } /** * showGrade1 - showGrade8 * * displays the evaluated grade * * @param {void} * @returns {void} */ CGPACalculator.prototype.showGrade1 = function() { var score1 = document.getElementById('score1').value; var unit = document.getElementById('units1').value; var grade = this.evaluateScore(score1); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade1').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points1'); } CGPACalculator.prototype.showGrade2 = function() { var score2 = document.getElementById('score2').value; var unit = document.getElementById('units2').value; var grade = this.evaluateScore(score2); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade2').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points2'); } CGPACalculator.prototype.showGrade3 = function() { var score3 = document.getElementById('score3').value; var unit = document.getElementById('units3').value; var grade = this.evaluateScore(score3); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade3').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points3'); } CGPACalculator.prototype.showGrade4 = function() { var score4 = document.getElementById('score4').value; var unit = document.getElementById('units4').value; var grade = this.evaluateScore(score4); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade4').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points4'); } CGPACalculator.prototype.showGrade5 = function() { var score5 = document.getElementById('score5').value; var unit = document.getElementById('units5').value; var grade = this.evaluateScore(score5); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade5').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points5'); } CGPACalculator.prototype.showGrade6 = function() { var score6 = document.getElementById('score6').value; var unit = document.getElementById('units6').value; var grade = this.evaluateScore(score6); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade6').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points6'); } CGPACalculator.prototype.showGrade7 = function() { var score7 = document.getElementById('score7').value; var unit = document.getElementById('units7').value; var grade = this.evaluateScore(score7); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade7').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points7'); } CGPACalculator.prototype.showGrade8 = function() { var score8 = document.getElementById('score8').value; var unit = document.getElementById('units8').value; var grade = this.evaluateScore(score8); var poin = this.computePoint(unit, grade); this.units.push(unit); $('#grade8').val(grade); this.points.push(poin); var point = this.evaluateGrade(grade); this.showPoint(point, '#points8'); this.computeTotalPoint(this.points); this.computeCGPA(); } /** * showPoint * * displays the associated * points of the grade * * @param {point} number * @param {elementId} number * @returns {void} */ CGPACalculator.prototype.showPoint = function(point, elementId) { $(elementId).val(point); } /** * computeTotalUnits * * computes the * total units * * @param {void} * @returns {void} */ CGPACalculator.prototype.computeTotalUnits = function() { for (var i=0; i&amp;lt;this.units.length; i++) { this.totalUnits = this.totalUnits + parseInt(this.units[i]); } } /** * computeCGPA * * computes the * cgpa * * @param {void} * @returns {void} */ CGPACalculator.prototype.computeCGPA = function() { this.computeTotalUnits(); this.cgpa = this.totalPoints / this.totalUnits; this.showCGPA(); this.showTotalPoints(); } /** * showCGPA * * displays the * cgpa * * @param {void} * @returns {void} */ CGPACalculator.prototype.showCGPA = function() { $('#cgpa').val(this.cgpa); } /** * showTotalPoints * * displays the * total points * * @param {void} * @returns {void} */ CGPACalculator.prototype.showTotalPoints = function() { $('#total').val(this.totalPoints); } /** * readJSONFile * * reads the json * file containing the * courses object * * @param {file} String * @param {callback} function * @returns {void} */ CGPACalculator.prototype.readJSONFile = function(file, callback) { var rawFile = new XMLHttpRequest(); rawFile.overrideMimeType("application/json"); rawFile.open("GET", file, true); rawFile.onreadystatechange = function() { if (rawFile.readyState === 4 &amp;amp;&amp;amp; rawFile.status == "200") { callback(rawFile.responseText); } } rawFile.send(null); }
Line 483 to line 493 is the code for reading the JSON file. This is required to avoid bundling in the actual implementation of the web page for the application.
The code for the implementation of the web page for the application is shown in section N of the text file for the codes. Now on running the test again, you will see the test passing as shown in the below figure. That is it for front-end unit testing jquery web applications using Jasmine, Jasmine-jquery, Karma, and gulp.
<!DOCTYPE html> <htmllang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="keywords" content=""> <meta name="description" content=""> <title>CGPA Calculator Application</title> <!-- Loading Bootstrap --> <linkhref="css/bootstrap.css" rel="stylesheet"> <!-- Loading Template CSS --> <linkhref="css/styles.css" rel="stylesheet"> </head> <body> <divclass="container" id="fixture"> <divclass="row"> <divclass="col-md-12 col-sm-12 col-lg-12"> <inputid="surname" type="text" placeholder="Surname"> <inputid="firstName" type="text" placeholder="firstName"> <inputid="middleName" type="text" placeholder="middleName"> <inputid="registrationNumber" type="text" placeholder="registrationNumber"> <selectid="level"> <optionvalue="">Please Select</option> <optionvalue="course1L">100L</option> <optionvalue="course2L">200L</option> <optionvalue="course3L">300L</option> <optionvalue="course4L">400L</option> <optionvalue="course5L">500L</option> </select> <selectid="courseOfStudy"> <optionvalue="">Please Select</option> <optionvalue="Computer">Computer Science</option> <optionvalue="Geology">Geology</option> <optionvalue="Mathematics">Mathematics</option> <optionvalue="Physics">Physics</option> <optionvalue="Mechanical Engineering">Mechanical Engineering</option> </select> <selectid="semester" onchange="cGPACalculator.enableLoadCourses()"> <optionvalue="">Please Select</option> <optionvalue="Rain">Rain</option> <optionvalue="Harmattan">Harmattan</option> </select> <buttonid="loadCourses" onclick="cGPACalculator.showInputTable()" disabled> Load Courses </button> </div> </div> <divclass="row"> <divclass="col-md-12 col-sm-12 col-lg-12"> <divid="table"> <table> <thead> <th>Code</th> <th>Title</th> <th>Units</th> <th>Score</th> <th>Grade</th> <th>Points</th> </thead> <tbody> <tr> <td><inputid="code1" class="code" readonly="readonly"></td> <td><inputid="title1" class="title" readonly="readonly"></td> <td><inputid="units1" class="units" readonly="readonly" type="number"></td> <td><inputid="score1" class="score" onblur="cGPACalculator.showGrade1()"></td> <td><inputid="grade1" class="grade" readonly="readonly"></td> <td><inputid="points1" class="points" readonly="readonly"></td> </tr> <tr> <td><inputid="code2" class="code" readonly="readonly"></td> <td><inputid="title2" class="title" readonly="readonly"></td> <td><inputid="units2" class="units" readonly="readonly" type="number"></td> <td><inputid="score2" class="score" onblur="cGPACalculator.showGrade2()"></td> <td><inputid="grade2" class="grade" readonly="readonly"></td> <td><inputid="points2" class="points" readonly="readonly"></td> </tr> <tr> <td><inputid="code3" class="code" readonly="readonly"></td> <td><inputid="title3" class="title" readonly="readonly"></td> <td><inputid="units3" class="units" readonly="readonly" type="number"></td> <td><inputid="score3" class="score" onblur="cGPACalculator.showGrade3()"></td> <td><inputid="grade3" class="grade" readonly="readonly"></td> <td><inputid="points3" class="points" readonly="readonly"></td> </tr> <tr> <td><inputid="code4" class="code" readonly="readonly"></td> <td><inputid="title4" class="title" readonly="readonly"></td> <td><inputid="units4" class="units" readonly="readonly" type="number"></td> <td><inputid="score4" class="score" onblur="cGPACalculator.showGrade4()"></td> <td><inputid="grade4" class="grade" readonly="readonly"></td> <td><inputid="points4" class="points" readonly="readonly"></td> </tr> <tr> <td><inputid="code5" class="code" readonly="readonly"></td> <td><inputid="title5" class="title" readonly="readonly"></td> <td><inputid="units5" class="units" readonly="readonly" type="number"></td> <td><inputid="score5" class="score" onblur="cGPACalculator.showGrade5()"></td> <td><inputid="grade5" class="grade" readonly="readonly"></td> <td><input id="points5" class="points" readonly="readonly"></td> </tr> <tr> <td><input id="code6" class="code" readonly="readonly"></td> <td><input id="title6" class="title" readonly="readonly"></td> <td><input id="units6" class="units" readonly="readonly" type="number"></td> <td><input id="score6" class="score" onblur="cGPACalculator.showGrade6()"></td> <td><input id="grade6" class="grade" readonly="readonly"></td> <td><input id="points6" class="points" readonly="readonly"></td> </tr> <tr> <td><input id="code7" class="code" readonly="readonly"></td> <td><input id="title7" class="title" readonly="readonly"></td> <td><input id="units7" class="units" readonly="readonly" type="number"></td> <td><input id="score7" class="score" onblur="cGPACalculator.showGrade7()"></td> <td><input id="grade7" class="grade" readonly="readonly"></td> <td><input id="points7" class="points" readonly="readonly"></td> </tr> <tr> <td><input id="code8" class="code" readonly="readonly"></td> <td><input id="title8" class="title" readonly="readonly"></td> <td><input id="units8" class="units" readonly="readonly" type="number"></td> <td><input id="score8" class="score" onblur="cGPACalculator.showGrade8()"></td> <td><input id="grade8" class="grade" readonly="readonly"></td> <td><input id="points8" class="points" readonly="readonly"></td> </tr> <tr> <td colspan="3">Total points Acquired</td> <td colspan="3"><input class="total" id="total" readonly="readonly"></td> </tr> <tr> <td colspan="3">CGPA</td> <td colspan="3"><input class="cgpa" id="cgpa" readonly="readonly"></td> </tr> </tbody> </table> </div> </div> </div> </div> <script src="../node_modules/jquery/dist/jquery.js"></script> <script src="js/bootstrap.min.js"></script> <script src="js/lib/underscore.js"></script> <script src="js/CGPACalculator.1.js"></script> <script> var cGPACalculator = new CGPACalculator(); </script> </body> </html>
All Jasmine Test Passes On Karma
The Github repo for the above codes can be found here.
Conclusion
In this tutorial, we tried to understand how to put into practice all that we learned about Karma, Jasmine, and Jasmine-Jquery. We also saw how the other node-based packages like gulp, browserify, brfs, etc. can be used to facilitate the process. We used a sample project as well to illustrate this.
Note: Jasmine-jquery can also be used to test front-end styling. You can also explore how to do that.
Takeaways
- Gulp is a node-based package that is used to run some fundamental tasks such as starting up a server (together with gulp-connect), Bundling files (together with browserify), Transpiling codes (together with babel), Running Shell commands, etc.
- To use browserify with karma, we also need to install karma-browserify.
- When loading files for karma, the independent files are loaded first, and the dependent ones are loaded next.
- The underscore package is a node-based package that provides us with functions to process data of type object and array.
- Source maps are a mapping of a minified and bundled file to its original source.
Assignment
Write a test for a function setUserDetails, which calls setSurname, setFirstName, and setMiddleName, and validate that the arguments given are of the valid type.
Also, write tests for the arguments passed to startComputation apart from courses, and then implement the functions to test the TDD workflow.
Note: Try using Jasmine spies to ensure that setUserDetails call setFirstName, setMiddleName, and setSurname.
You can attempt spying other functions that startComputation called which we didn’t spy in our test. Finally, think of the ways in which you can make the application flow better and write associated tests for it if the need arises.
PREV Tutorial | FIRST Tutorial