Marmelab Blog

End-to-end Testing For Single-Page Apps With No Server

Single-Page Apps (like the ones built with React.js) are often made of a small HTML document loading a large JavaScript file. In order to do end-to-end testing of such applications with Selenium, you must setup a web server to publish the HTML and JS file, and make it accessible by the test browser. This adds to the complexity of setting up e2e tests, which is already pretty high.

What if there was a way to test SPAs without a web server? It turns out it's possible, but a bit hackish. Read on to discover all the tricks required to make this work.

Loading an empty HTML document

The basis for single-page apps is a small HTML document, usually looking like:

<html>
    <body>
        <div id="root"></div>
        <script src="static/bundle.js">
        </script>
    </body>
</html>

We'll temporarily forget about the <script> tag, because it requires a web server. So the question is: How can I tell Selenium to load this HTML code without hosting it somewhere?

The first idea is to use a 'data:' URI:

// in e2e/tests/home.js
import chromedriver from 'chromedriver';
import webdriver from 'selenium-webdriver';

const driver = new webdriver.Builder()
    .forBrowser('chrome')
    .build();

describe('home', () => {
    before(async () => {
        await driver.navigate.to('data:text/html,%3Cdiv id%3D%22root%22%3E%3C%2Fdiv%3E');
    });
})

Tip: We're using async and await here, which are ES7 statements that you can use right now with babel.

This works: Selenium tells Chrome to load the document <div id="root"></div>, and the browser executes accordingly. But this has serious limitations: browsers disallow many features when called from a 'data:' URI (like localSorage), and these features are always used by SPAs.

The solution is to first load a blank web page (which must be hosted somewhere on the web), then add the HTML code using driver.executeScript() and a good old document.write:

// in e2e/utils/localTesting.js
/**
 * Load an HTML string into the browser
 *
 * This function first loads an empty web page hosted somewhere on the net,
 * then writes the html to the body.
 *
 * @param {String} html A string of HTML. Will be ascaped.
 * @param {SeleniumWebDriver} driver
 */
export async function loadHtml(html, driver) {
    const encodedHtml = encodeURIComponent(html);
    await driver.navigate().to('http://static.marmelab.com/blank/');
    await driver.executeScript(`document.write(decodeURIComponent('${encodedHtml}'))`);
}

// in e2e/tests/home.js
import chromedriver from 'chromedriver';
import webdriver from 'selenium-webdriver';
import { loadHtml } from '../utils.localTesting';

const driver = new webdriver.Builder()
    .forBrowser('chrome')
    .build();

describe('home', () => {
    before(async () => {
        await loadHtml('<div id="root"></div>', driver);
    });
});

Loading an Large JavaScript File

Now that the HTML is ready, how can you send the static/bundle.js file to the browser to have it executed? Using driver.executeScript() of course:

// in e2e/tests/home.js
import chromedriver from 'chromedriver';
import webdriver from 'selenium-webdriver';
import fs from 'fs';
import path from 'path';
import { loadHtml } from '../utils.localTesting';

const driver = new webdriver.Builder()
    .forBrowser('chrome')
    .build();

describe('home', () => {
    before(async () => {
        await loadHtml('<div id="root"></div>', driver);
        const script = fs.readFileSync(path.join(__dirname, '../../example/static/bundle.js'), 'utf8')
        await driver.executeScript(script);
    });
});

Except that it doesn't work. If you execute that script, you'll get the following error:

WebDriverError: disconnected: Unable to receive message from renderer

The reason is that static/bundle.js is often a large file (larger than 1MB) because, when compiled for tests, SPAs are not optimized. Selenium WebDriver uses WebSockets to send scripts to the browser, and Chrome has a hard limit of about 1MB on WebSocket messages.

The solution... isn't very elegant. You've been warned.

The solution is to cut the large script into smaller chunks, send these chunks to the browser one by one, then reassemble them and execute the whole using a good old eval():

// in e2e/utils/localTesting.js
/**
 * Execute a large string of JavaScript in the browser
 *
 * driver.executeScript() fails for scripts larger than 1MB, because the driver
 * uses WebSockets to push the script to the browser. As a consequence, we have
 * to split the script into smaller chunks, push the chunks one by one, and
 * reassemble them on the browser side using a final (and ugly) `eval`.
 *
 * Use it at your own risks!
 *
 * @param {String} script A large string of JS file. Will be escaped.
 * @param {SeleniumWebDriver} driver
 */
export async function executeLongScript(script, driver) {
    const CHUNK_SIZE = 700000; // about 700kB
    const scriptLength = script.length;
    // cut the script in smaller chunks
    let chunks = [];
    let i;
    for (i = 0; i < scriptLength; i += CHUNK_SIZE) {
        chunks.push(script.substring(i, i + CHUNK_SIZE));
    }
    // put each chunk into a global JS variable (source0 = "...", source1 ="...", etc.)
    chunks = chunks.map((chunk, index) => `source${index} = "${encodeURI(chunk)}";`);
    // send all chunks to the browser
    await Promise.all(chunks.map(chunk => driver.executeScript(chunk)));
    // create the assembling script (source = source1 + source2 + ...; eval(source);)
    const assembledScript = 'source=' + chunks.map((chunk, index) => `decodeURI(source${index})`).join('+') + ';eval(source);';
    // send the final script to the browser
    await driver.executeScript(assembledScript);
}

// in e2e/tests/home.js
import chromedriver from 'chromedriver';
import webdriver from 'selenium-webdriver';
import fs from 'fs';
import path from 'path';
import { loadHtml, executeLongScript } from '../utils.localTesting';

const driver = new webdriver.Builder()
    .forBrowser('chrome')
    .build();

describe('home', () => {
    before(async () => {
        await loadHtml('<div id="root"></div>', driver);
        const script = fs.readFileSync(path.join(__dirname, '../../example/static/bundle.js'), 'utf8')
        await executeLongScript(script, driver);
    });
});

And you're all set. If the script contains a React App, this app will correctly mount on the HTML document, and you can then start e2e tests using Selenium (as described in a previous post).

Wait, How About AJAX Calls?

Your SPA is probably relying on a REST API to fetch data. Does that mean that you need a server after all?

No it doesn't, because you can use FakeRest. FakeRest is a JavaScript library which acts as a REST server, but runs in the browser. You just have to plug it to XMLHttpRequest or fetch to have a self-contained, completely serverless app:

// in example/app.js
import FakeRest from 'fakerest';
import fetchMock from 'fetch-mock';
import data from './data';
/**
 * data looks like:
 * {
 *   posts: [
 *     { id: 0, title, 'Hello, world!' },
 *     { id: 1, title, 'FooBar' },
 *   ],
 *   comments: [
 *     { id: 0, post_id: 0, author: 'John Doe', body: 'Sensational!' },
 *     { id: 1, post_id: 0, author: 'Jane Doe', body: 'I agree' },
 *   ],
 * }
 */

const restServer = new FakeRest.FetchServer('http://localhost:3000');
restServer.init(data);
// plug the restServer in front of fetch()
fetchMock.mock('^http://localhost:3000', restServer.getHandler());

Now, every time the SPA calls fetch('http://localhost:3000/'), it gets the pre-canned data, without any server:

fetch('http://localhost:3000/posts/1')
// { id: 1, title, 'FooBar' }
fetch('http://localhost:3000/comments')
// [
//    { id: 0, post_id: 0, author: 'John Doe', body: 'Sensational!' },
//    { id: 1, post_id: 0, author: 'Jane Doe', body: 'I agree' },
// ]

FakeRest acts as a full-featured REST server, can paginate, sort, filter, embed records into one another... It can completely replace a REST server when you don't want to run one.

Is It Worth It?

Loading a large JavaScript file using executeLongScript() turns out to be a bit slow. That's because chunking, encoding, and decoding x large strings takes some CPU and memory. But mostly, that's because sending a large volume of data over WebSocket (the protocol used by Selenium WebDriver) is inefficient ; WebSockets lack compression, caching and state management. HTTP is much more efficient for loading large files.

Besides, it's not that hard to spin-up a small HTTP server on demand for e2e tests:

// in e2e/tests/home.js
import chromedriver from 'chromedriver';
import webdriver from 'selenium-webdriver';
import express from 'express';
import path from 'path';

const driver = new webdriver.Builder()
    .forBrowser('chrome')
    .build();

let listeningServer;

describe('home', () => {
    before(async () => {
        const server = express();
        server.use('/', express.static(path.join(__dirname, '../../example')));
        listeningServer = server.listen(8081);
        await driver.navigate().to('http://localhost:8081/');
    });
    after(() => {
        listeningServer.close();
    });
});

Conclusion

Did you just read the long description of a solution that's not useful in practice? I'm afraid so: now you know that it's possible but not practical. FakeRest remains good advice regardless of Selenium (we use it to test React components in a browser). Finally, doing e2e tests with Selenium is not that hard, so happy testing!