Testing XHR with Mocha, Chai, and SinonJS

  1. Overview
    1. Mocha is the test runner
    2. Chai is the assertion library
    3. SinonJS is used to mock an external service
    4. Mock testing is a unit test where an external dependency is replaced with an object that "mocks" or simulates the dependency's behavior
    5. Ex: When testing a notification service that sends an email, we could replace the email-sending object with a mock object that pretends to send emails so emails are not sent when unit testing the notification service
    6. Mocking XMLHttpRequest (XHR) is common when testing code that sends XHR requests so no actual request is sent
  2. Testing in the browser
    1. Use the web page below as a template to test code (movieapi.js)
      <!DOCTYPE html>
      <html>
      <head>
        <!-- Mocha CSS to style the results -->
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.css">
        
        <!-- Mocha framework -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.js"></script>
        <script>
          mocha.setup("bdd"); // minimal setup
        </script>
        
        <!-- SinonJS for mocking XHR -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/sinon.js/9.0.3/sinon.min.js"></script>
        
        <!-- Chai -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/4.2.0/chai.js"></script>
        <script>
          // Make chai assert global
          const assert = chai.assert;
        </script>
      </head>
      <body>
        <!-- Script with code to test -->
        <script src="movieapi.js"></script>
      
        <!-- Script with tests -->
        <script src="test.js"></script>
      
        <!-- Show test results -->
        <div id="mocha"></div>
      
        <!-- Run tests -->
        <script>
          mocha.run();
        </script>
      </body>
      </html>
      
    2. movieapi.js contains object to be tested
      // movieapi.js
      const movieApi = {    
      	apiKey: "API KEY",
      	baseUrl: "https://www.omdbapi.com/",
         
      	getMovies: function(searchStr, callback) {
      		const xhr = new XMLHttpRequest();
      		xhr.addEventListener("load", function() {
      			if (this.response.Error) {
      				callback(this.response.Error);
      			}
      			else {
      				callback(null, this.response.Search);
      			}
      		});
      		
      		xhr.responseType = "json";
      		xhr.open("GET", `${this.baseUrl}?s=${encodeURIComponent(searchStr)}&type=movie&apikey=${this.apiKey}`); 
      		xhr.send();
      	} 
      };
      
    3. test.js runs tests
      1. Mocha
        1. beforeEach(func) and afterEach(func) - Mocha functions that run code before and after each test in the block
        2. done() - Mocha function that indicates asynchronous test is finished executing (parameter from it() function)
      2. Chai
        1. assert.deepEqual(actual, expected) - Instead of testing if references refer to same array in memory (actual === expected), deep equality verifies the arrays contain elements with the same values
      3. SinonJS
        1. sinon.useFakeXMLHttpRequest() - Returns a mock or "fake" object for XHR called FakeXMLHttpRequest
          // Under the hood
          const savedXhr = window.XMLHttpRequest;
          window.XMLHttpRequest = FakeXMLHttpRequest;
          
        2. xhr.onCreate = function(xhr) - Subscribe to newly created FakeXMLHttpRequest objects
        3. xhr.restore() - Restore original functions
          // Under the hood
          window.XMLHttpRequest = savedXhr;
          
      // test.js
      describe("movieApi object", function() {
      
          describe("getMovies() method", function() {
      	
              beforeEach(function() {
      			// Replace XMLHttpRequest with FakeXMLHttpRequest
                  this.xhr = sinon.useFakeXMLHttpRequest();
              
      			// For mocking responses to requests that movieApi will send 
                  this.requests = [];
      			
      			// Subscribe 
                  this.xhr.onCreate = function(xhr) {
                      this.requests.push(xhr);
                  }.bind(this);
              });
              
              afterEach(function() {
      			// Restore original XMLHttpRequest
                  this.xhr.restore();
              });
      
              it("Makes XHR request with valid search string and returns an array of movies", function(done) {
                  const searchStr = "star";
      
      			// Expected JSON response when searching for "star"
                  const jsonStr = `
                  {
                      "Search": [
                      {
                          "Title": "Star Wars: Episode IV - A New Hope",
                          "Year": "1977",
                          "imdbID": "tt0076759",
                          "Type": "movie"
                      },
                      {
                          "Title": "Star Wars: Episode V - The Empire Strikes Back",
                          "Year": "1980",
                          "imdbID": "tt0080684",
                          "Type": "movie"
                      },                
                      {
                          "Title": "Star Trek II: The Wrath of Khan",
                          "Year": "1982",
                          "imdbID": "tt0084726",
                          "Type": "movie"
                      }
                      ]
                  }
                  `;
      
      			// Get array of movie objects created by JSON 
                  const movieData = JSON.parse(jsonStr).Search;
      
                  movieApi.getMovies(searchStr, function(err, results) {
      			
      				// Should be no error 
                      assert.isNull(err);
      				
      				// Deep equality means the arrays contain elements with the same values 
                      assert.deepEqual(results, movieData);
                      done();
                  });
      
                  // Supply fake XHR response
                  this.requests[0].respond(200, { 'Content-Type': 'text/json' }, jsonStr);
              });
      
              it("Makes XHR request with invalid search string and returns an error", function(done) {
                  const searchStr = "sdfsdfdsfds";
                  const jsonStr = '{"Response":"False","Error":"Movie not found!"}';
      
                  movieApi.getMovies(searchStr, function(err, results) {
                      assert.equal(err, "Movie not found!");
                      done();
                  });
      
                  // Supply fake XHR response
                  this.requests[0].respond(200, { 'Content-Type': 'text/json' }, jsonStr);
              });
          });
       });