Sailing with Node and React

Presenter: Ashley Simons

About the author

  • Commercial developer since early 2009
  • Was mainly PHP, but focusing on frontend and Node right now.
  • Find me at ashleysimons.net

React.js

"A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES"

React is ...

  • React is the view layer in an application
  • Declare what your DOM should look like
  • Re-render the smallest portion
  • Look ma! No jQuery
import React from 'react';
import validator from 'validator';

export default class TextInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: '', validate: false };
    this.isValid = this.isValid.bind(this);
    this.validationMessage = this.validationMessage.bind(this);
    this.onChange = this.onChange.bind(this);
    this.shouldValidateForm = this.shouldValidateForm.bind(this);
    this.onBlur = this.onBlur.bind(this);
  }
  isValid(){
    if(this.props.required && !this.props.value){
      this.message = `${this.props.label} is required. `;
      return false;
    }
    if(this.props.type == "email" && !validator.isEmail(this.props.value)){
      this.message = "Must be a valid email address. ";
      return false;
    }
    if(this.props.minLength && !validator.isLength(this.props.value, {min:this.props.minLength, max: undefined})){
      this.message = `Must be at least ${this.props.minLength} characters. `;
      return false;
    }
    return true;
  }
  onChange(e){
    this.props.onTextChange(e.target.value);
  }
  validationMessage(){
    return this.message;
  }
  shouldValidateForm(){
    return this.props.shouldValidateForm || this.state.validate;
  }
  onBlur(e){
    this.setState({validate: true});
  }
  render() {
    const { label, required, type, value, id, name, placeholder } = this.props;
    return (
      <div className={ (this.shouldValidateForm() && !this.isValid()) ? "form-group has-error": "form-group" }>
        <label htmlFor={id}>{label} { required && <span className="required-input">*</span> } </label>
        <input type={type == "password" ? "password" : "text"} onBlur={this.onBlur} id={id} className="form-control"
              name={name} placeholder={placeholder} value={value} onChange={this.onChange}  />
        { (this.shouldValidateForm() && !this.isValid())
              && <p className="help-block">{this.validationMessage()}</p> }
      </div>
      );
   }
}
            
<form method="POST" onSubmit={this.onSubmit} id="loginForm">
  <TextInput
    name="email"
    id="email"
    value={this.props.username}
    label="Email"
    required={true}
    type="email"
    onTextChange={this.props.onUsernameChange}
    shouldValidateForm={this.props.shouldValidateForm}
    ref={ textInput => this.usernameInput = textInput }
  />
  <TextInput
    name="password"
    id="password"
    value={this.props.password}
    label="Password"
    type="password"
    required={true}
    shouldValidateForm={this.props.shouldValidateForm}
    onTextChange={this.props.onPasswordChange}
    ref={ textInput => this.passwordInput = textInput }
  />
  <input type="submit" id="submit" className="btn btn-primary btn-lg m-t-xs col-xs-12" value="Login" />
</form>
            

How are we going sailing?

Purpose

  • To get a concrete idea of what your first week will be like
  • So you can go compare it to LoopBack - The Node.js API Framework
  • Your backend devs have to learn JavaScript properly anyway
    • Why not have the frontend devs pitch in on the backend

Sails.js

  • Rails like full featured MVC framework
  • Express based
    • Mix of popular middleware + custom projects
      Eg: Sails specific ORM = Waterline
  • 6600+ commits, 233+ releases, 211 contributors
  • Since 2012
  • MIT licence
  • 56,268 downloads last month

Continued

  • Official commercial support
    • The Sails Company (formally Balderdash)
    • Support tickets w/ 24 hour response times from $600 USD per month
    • Code reviews and faster response times for larger teams are more pricey
    • Core developers Mike McNeil & Scott Gress still part of company
  • Has an active community live chat: gitter.im/balderdashy/sails
  • .tmp
    • public static assets piped here
      • js
      • styles
  • api
    • controllers
    • models
    • policies isAuthenticated.js
    • responses badRequest.js
    • services UserService.js
  • assets
    • images
    • js
    • styles supports LESS
    • templates JST frontend templates
  • config
    • env environment specific overrides
    • locale
  • node_modules file descriptor limit stress test
  • tasks
    • config configure existing grunt tasks
    • register define new ones
  • views ejs view templates

Amost everything you were afraid to ask about

Modern JavaScript

ES6 / ES2015

let VS const

Block scoped `let`

let foo = 'bah';
foo = "fred";

`const` also block scoped

const user = { firstname: "Jeff" } ;
user.surname = "Hobbs";

Arrow Functions

Regular Function
const myFunction = function(first, second){
  // `this` keyword is initialised here
  return first + second;
}
Arrow Function
const myFunction = (first, second) => {
  // `this` will only exist if it already exists
  return first + second;
}

Spread Syntax

... spreads out your arrays and `iterable` objects

const myFunction = function(){
  for(let value of arguments) {
    console.log(value);
  }
}
const arrayToMerge = ["bazz", "dance"];
const myArray = [...arrayToMerge, "foo", "bah", "zip"];
myFunction(...myArray);
// ouputs:
// arg is bazz
// arg is dance
// arg is foo
// arg is bah
// arg is zip

Promises

Chain asynchronous along in order

CipherService.hashPassword(user)
  .then(function(user){
    return user.saveWithPromise();
  })
  .then(function(user){
    user.firstname = 'New One';
    return user;
  })
  .then(function(user){
    // made it here despite not returning a promise
  }
  .catch(err => done(err)); // no catch = swallowed errors

... continued

hashPassword: function (user) {
  return new Promise(function(resolve, reject){
    bcrypt.genSalt(10, function(err, salt) {
      if (err) return reject(err);
      bcrypt.hash(user.password, salt, function(err, hash) {
        if (err) return reject(err);

        user.password = hash;
        resolve(user);
      });
    });
  });
}

Project Setup

$ npm install sails -g

$ sails new sails-react-example

$ cd sails-react-example

$ npm install --save sails-disk prototype models/save to disk

Run with:

$ sails lift

React + Webpack

$ npm install --save-dev webpack grunt-webpack

$ npm i -S react react-dom

$ npm i -D babel-core babel-loader babel-preset-env babel-preset-react babel-preset-airbnb

$ npm install --save babel-polyfill async/await support anyone?

Create tasks/config/webpack.js

const path = require('path');
const BASE_DIR = path.resolve(__dirname, '../..');
const BUILD_DIR = path.resolve(__dirname, '../../', '.tmp/public/js');
const APP_DIR = path.resolve(__dirname, '../../', 'assets/js');

module.exports = function(grunt) {
  grunt.config.set('webpack', {
    dev: {
      context: BASE_DIR,
      entry: ['babel-polyfill', APP_DIR + '/index.jsx'],
      output: {
        path: BUILD_DIR,
        filename: 'bundle.js'
      },
      module: {
        loaders: [
          {
            test: /\.jsx?/,
            include: APP_DIR,
            loader: 'babel-loader',
            options: {
              presets: ["env", "react"]
            }
          }
        ]
      }
    }
  });
  grunt.loadNpmTasks('grunt-webpack');
};
						

Edit tasks/config/copy.js

module.exports = function(grunt) {
  grunt.config.set('copy', {
    dev: {
      files: [{
        expand: true,
        cwd: './assets',
        src: ['**/*.!(coffee|less|jsx)'],
        dest: '.tmp/public'
      }],
    },
    build: {
      files: [{
        expand: true,
        cwd: '.tmp/public',
        src: ['**/*'],
        dest: 'www'
      }]
    }
  });
  grunt.loadNpmTasks('grunt-contrib-copy');
};
						

Edit tasks/register/compileAssets.js

module.exports = function(grunt) {
  grunt.registerTask('compileAssets', [
    'clean:dev',
    'jst:dev',
    'less:dev',
    'copy:dev',
    'coffee:dev',
    'webpack:dev'
  ]);
};
						

Edit tasks/register/syncAssets.js

module.exports = function(grunt) {
  grunt.registerTask('syncAssets', [
    'jst:dev',
    'less:dev',
    'sync:dev',
    'coffee:dev',
    'webpack:dev'
  ]);
};
						

Edit views/layout.ejs


							...
							
							...
						

Edit views/homepage.ejs

Create assets/js/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';

const { render } = ReactDOM;

class HelloWorld extends React.Component {
  render() {
    return (
      
Hello World
); }; } render ( <HelloWorld />, document.getElementById("react-container") );

GitHub

ashleysimons/sails-react-example

Basic install of webpack/react ... e8627884

MySQL & Models

with Waterline

Setup

$ npm i -S sails-mysqldatabase adapter

$ npm i -S dotenvone config for sails and migrations

$ npm i -g db-migrate db-migrate-mysql database migrations

MySQL connection

Update config/connections.js with

...
someMysqlServer: {
  adapter: 'sails-mysql',
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE_NAME
},
testMysqlServer: {
  adapter: 'sails-mysql',
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_TEST_DATABASE_NAME
},
...
          

Update config/models.js

module.exports.models = {
  connection: 'someMysqlServer',
  migrate: 'safe'
};
          

dotenv setup

Add to top of config/bootstrap.js

require('dotenv').config();
...

Create .env.example and copy to .env

MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_HOST=127.0.0.1
MYSQL_DATABASE_NAME=demo
MYSQL_TEST_DATABASE_NAME=demo_test
          

Migrations setup

Create database.json

{
  "dev": {
    "host": { "ENV" : "MYSQL_HOST" },
    "user": { "ENV" : "MYSQL_USER" },
    "password" : { "ENV" : "MYSQL_PASSWORD" },
    "database": { "ENV" : "MYSQL_DATABASE_NAME" },
    "driver": "mysql",
    "multipleStatements": true
  }
}

$ db-migrate create users

Update migrations/20170309030724-users.js

'use strict';

var dbm;
var type;
var seed;

/**
  * We receive the dbmigrate dependency from dbmigrate initially.
  * This enables us to not have to rely on NODE_PATH.
  */
exports.setup = function(options, seedLink) {
  dbm = options.dbmigrate;
  type = dbm.dataType;
  seed = seedLink;
};

exports.up = function(db) {
  return db.runSql("CREATE TABLE `user` (\
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\
    `firstname` varchar(255) DEFAULT NULL,\
    `surname` varchar(255) DEFAULT NULL,\
    `email` varchar(255) DEFAULT NULL,\
    `password` varchar(255) DEFAULT NULL,\
    `terms` tinyint(1) DEFAULT '0',\
    `createdAt` datetime DEFAULT NULL,\
    `updatedAt` datetime DEFAULT NULL,\
    PRIMARY KEY (`id`),\
    UNIQUE KEY `email` (`email`)\
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8");
};

exports.down = function(db) {
  return db.runSql("DROP TABLE `user`");
};

exports._meta = {
  "version": 1
};

          

$ db-migrate up

$ sails generate api user generates controller and model


Sails maps controllers and models with the same name into automatic REST behaviour by default. It's called the Blueprint API or Blueprints.

Update api/models/User.js

/**
 * User.js
 *
 * @description :: This represents a user in the system.
 * @docs        :: http://sailsjs.org/documentation/concepts/models-and-orm/models
 */
module.exports = {
  attributes: {
    firstname: {
      type: 'string',
      required: 'true',
    },
    surname: {
      type: 'string',
      required: 'true',
    },
    password: {
      type: 'string',
      minLength: 10,
      required: true,
    },
    email: {
      type: 'string',
      unique: true,
      index: true,
      required: true,
    },
    terms: {
      type: 'boolean',
      required: true,
      boolean: true,
    },
    toJSON: function(){
      const obj = this.toObject();
      delete obj.password;
      return obj;
    }
  },
  beforeValidate: function(user, cb){
    user.terms = (user.terms == 'on' || user.terms == true) ? true : undefined;
    cb();
  },
  beforeCreate: function(user, cb) {
    // hash password
    cb();
  }
}

Look ma! No controller logic.

GET http://localhost:1337/user

GET http://localhost:1337/user/create?firstname=foo

GitHub

ashleysimons/sails-react-example

Setting up MySQL, migrations and dotenv ... d45a7df

wishing Adelaide is big enough for

Setting Up Testing

with enzyme and mocha

Why Adelaide is big enough

  • 70% sustainable velocity can be achieved through adopting some TDD philosophy
  • Inversely the alternative is unsustainable 110% velocity
  • Talk to business stake holders and be brave
    • No one likes rebuilds
    • No ones likes to hear Jeff built that and he's gone
    • That whole section is a beast and I'll break something

Testing is good when

  • You need to deploy frequently
  • You have more than one person in the team
  • The person working on it will change
  • A pleasant user experience provides business value to you
  • You want a culture of professional growth as a team
  • It encourages refactoring
  • You want to avoid rebuilds
  • You want to upgrade your environment or node version

Getting started

$ npm i -D mochaour test runner

$ npm i -D babel-registerdynamically transpile all test code

$ npm i -D enzyme jsdom react-addons-test-utilsAirbnb framework, emulated dom

$ npm i -D sinon chaimocks/stubs, assert/expect

$ npm i -D fetch-mock node-localstoragehttp fetch mocking, html5 local storage

$ npm i -D mute mute output temporarily in tests

$ npm i -D barrels supertest fixtures, http requests in tests

Add test/bootstrap.test.js

const sails = require('sails');
require('babel-register')();
require('babel-polyfill'); // Not required for Node, but the test runner might be something else some time

const LocalStorage = require('node-localstorage').LocalStorage;

/**
 * print the stack of the error, rather than node just warning you and providing only the error message
 */
process.on('unhandledRejection', (reason, p) => {
  console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

const { JSDOM } = require('jsdom');
const jsdom = new JSDOM('');
const { window } = jsdom;

function copyProps(src, target) {
  const props = Object.getOwnPropertyNames(src)
    .filter(prop => typeof target[prop] === 'undefined')
    .map(prop => Object.getOwnPropertyDescriptor(src, prop));
  Object.defineProperties(target, props);
}

global.window = window;
global.document = window.document;
global.navigator = {
  userAgent: 'node.js'
};
copyProps(window, global);

global.FormData = document.defaultView.FormData;
global.localStorage = new LocalStorage('./.localStorage/');
global.window.localStorage = global.localStorage;

before(function(done) {

  // Increase the Mocha timeout so that Sails has enough time to lift.
  this.timeout(5000);

  sails.lift({
    log: {
      level: 'error'
    },
    models: {
      connection: 'testMysqlServer',
      migrate: 'drop'
    },
    environment: 'test',
  }, function(err) {
    if (err) return done(err);
    var Barrels = require('barrels');
    var barrels = new Barrels();
    global.fixtures = barrels.data;
    barrels.populate(function(err) {
      done(err, sails);
    });
  });
});

after(function(done) {
  // here you can clear fixtures, etc.
  sails.lower(done);
});
            

Add .babelrc

{
  presets: ["env", "react", "airbnb"]
}
            

Update package.json

// in the scripts section
"test": "NODE_PATH=`pwd` PORT=9999 NODE_ENV=test node ./node_modules/mocha/bin/mocha -R spec -b --recursive"
            

Add test/fixtures/User.json

[
  {
    "firstname": "Greg",
    "surname": "Applebee",
    "password": "demopassword",
    "email": "alpha_first_greg@example.com",
    "terms": "on"
  },
  {
    "firstname": "Jeff",
    "surname": "Jones",
    "password": "demopassword",
    "email": "alpha_second_jeff@example.com",
    "terms": "on"
  }
]
            

test/integration/controllers/UserController.test.js

import { expect, assert } from 'chai';
import request from 'supertest';

const alphaSortFunction = (a, b) => {
  if(a.email < b.email) return -1;
  if(a.email > b.email) return 1;
  return 0;
};

describe('UserController', function() {
  const userPath = '/user';
  let myRequest;

  before(function(){
    global.fixtures['user'].sort(alphaSortFunction);
  });

  describe(`GET ${userPath}`, function(){

    beforeEach(function(){
      myRequest = request(sails.hooks.http.app)
        .get(userPath)
        .set('Accept', 'application/json')
    });

    it('returns the same amount of users as in the fixtures', function(done){
      myRequest.end(function(err, res) {
        if (err) return done(err);
        expect(res.body).to.have.lengthOf(global.fixtures['user'].length);
        done();
      });
    });

    it('firstnames match the fixtures', function(done){
      myRequest.end(function(err, res) {
        if (err) return done(err);
          res.body.sort(alphaSortFunction);
          for(var i = 0; i < global.fixtures['user'].length;i++){
            expect(res.body[i].firstname).to.equal(global.fixtures['user'][i].firstname);
          }
          done();
        });
    });

    it('surnames match the fixtures', function(done){
        myRequest.end(function(err, res) {
          if (err) return done(err);
          res.body.sort(alphaSortFunction);
          for(var i = 0; i < global.fixtures['user'].length;i++){
            expect(res.body[i].surname).to.equal(global.fixtures['user'][i].surname);
          }
          done();
        });
    });

    it('emails match the fixtures', function(done){
        myRequest.end(function(err, res) {
          if (err) return done(err);
          res.body.sort(alphaSortFunction);
          for(var i = 0; i < global.fixtures['user'].length;i++){
            expect(res.body[i].email).to.equal(global.fixtures['user'][i].email);
          }
          done();
        });
    });

  });

  describe(`POST ${userPath}`, function() {

    it('should create user', function (done) {
      let firstname = 'Fred';
      let surname   = 'Bass';
      let email     = 'foo@example.com';
      let password  = 'mydemopassword';
      let terms     = 'on';
      request(sails.hooks.http.app)
        .post(userPath)
        .set('Accept', 'application/json')
        .send({ firstname: firstname, surname: surname, email: email, password: password, terms: terms })
        .expect('Content-Type', /json/)
        .expect(201)
        .end(function(err, res) {
          if (err) return done(err);
          assert.equal(res.body.firstname, firstname);
          assert.equal(res.body.surname, surname);
          assert.equal(res.body.email, email);
          done();
        });
    });

  });

});
            

test/integration/jsx/share.js

import React from 'react';
import { expect } from 'chai';
import { mount, render } from 'enzyme';
import Share from 'assets/js/components/share.jsx';
import sinon from 'sinon';
import {
  MemoryRouter as Router,
} from 'react-router-dom';

describe("<Share />", function() {

  let wrapper;
  const user = {
    publicUrls: [ {url: 'http://localhost/#/friend/jf0s34ljla'} ]
  };

  beforeEach(function () {
    document.execCommand = sinon.stub().returns(true);
    wrapper = mount(<Router><Share user={user} /></Router>);
  });

  afterEach(function(){
    delete document.execCommand;
  });

  describe("by default", function () {

    it("contains a share url", function () {
      expect(wrapper.find('.input-group').html()).to.contain(user.publicUrls[0].url);
    });

  });

  describe("upon icon click", function(){

    it("flashes a success about copying to clipboard", function(){
      wrapper.find('.hover').simulate('click', {});
      expect(wrapper.find('.input-group').html()).to.contain("Copied!");
    });

    it("copies to clipboard", function(){
      wrapper.find('.hover').simulate('click', {});
      expect(document.execCommand.calledOnce).to.be.true;
    });

  });

});

            

test/integration/jsx/login-container.test.js

import React from 'react';
import { expect, assert } from 'chai';
import { mount } from 'enzyme';
import LoginContainer from 'assets/js/components/login/login-container.jsx';
import Login from 'assets/js/components/login/login.jsx';
import fetchMock from 'fetch-mock';
import 'isomorphic-fetch';
import sinon from 'sinon';

describe("<LoginContainer /> integration", function() {
  let history;

  beforeEach(function(){
    history = { push: sinon.spy() };
  });

  describe("by default", function () {

    let wrapper;

    beforeEach(function () {
      wrapper = mount(<LoginContainer history={history} />);
    });

    it("renders a login component", function () {
      expect(wrapper.find(Login)).to.have.length(1);
    });

  });

  describe("upon empty submit", function(){
    let wrapper, onUserChange;

    beforeEach(function () {
      fetchMock.mock("*", {body: {user: "foo"}, status: 200});
      onUserChange = sinon.spy();
      wrapper = mount(<LoginContainer history={history} onUserChange={onUserChange} />);

      sinon.spy(FormData.prototype, 'append');

      wrapper.find('form').simulate('submit', { preventDefault: sinon.spy() });
    });

    afterEach(function(){
      fetchMock.restore();
      FormData.prototype.append.restore();
    });

    it("shows required validation errors", function(){
      expect(wrapper.find('#email').parent().hasClass('has-error')).to.be.true;
      expect(wrapper.find('#password').parent().hasClass('has-error')).to.be.true;
    });

    it("shows a danger flash message", function(){
      expect(wrapper.find('#status').text()).to.contain('There are missing');
      expect(wrapper.find('#status').hasClass('alert-danger')).to.be.true;
    });

  });

  describe("upon submit", function(){
    let wrapper, onUserChange;

    beforeEach(function () {
      fetchMock.mock("*", {body: {user: "foo"}, status: 200});
      onUserChange = sinon.spy();
      wrapper = mount(<LoginContainer history={history} onUserChange={onUserChange} />);

      wrapper.find('#email').simulate('change', {target: {value: global.fixtures['user'][0].email}});
      wrapper.find('#password').simulate('change', {target: {value: global.fixtures['user'][0].password}});

      sinon.spy(FormData.prototype, 'append');

      wrapper.find('form').simulate('submit', { preventDefault: sinon.spy() });
    });

    afterEach(function(){
      fetchMock.restore();
      FormData.prototype.append.restore();
    });

    it("submits the form with the expected fields", function(done){
      setTimeout(function(){
        expect(FormData.prototype.append.calledWith('email', global.fixtures['user'][0].email)).to.be.true;
        expect(FormData.prototype.append.calledWith('password', global.fixtures['user'][0].password)).to.be.true;
        done();
      }, 0);
    });

  });

});
            

test/unit/jsx/login-container.test.js

import React from 'react';
import { expect, assert } from 'chai';
import { shallow } from 'enzyme';
import LoginContainer from 'assets/js/components/login/login-container.jsx';
import Login from 'assets/js/components/login/login.jsx';
import fetchMock from 'fetch-mock';
import 'isomorphic-fetch';
import sinon from 'sinon';

describe("<LoginContainer />", function() {
  let history;

  beforeEach(function(){
    history = { push: sinon.spy() };
  });

  describe("by default", function () {

    let wrapper;

    beforeEach(function () {
      wrapper = shallow(<LoginContainer history={history} />);
    });

    it("renders a login component", function () {
      expect(wrapper.find(Login)).to.have.length(1);
    });

  });

  describe("upon submit", function(){
    let wrapper, onUserChange;

    beforeEach(function () {
      fetchMock.mock("*", {body: {user: "foo"}, status: 200});
      onUserChange = sinon.spy();
      wrapper = shallow(<LoginContainer history={history} onUserChange={onUserChange} />);
      wrapper.instance().onSubmit();
    });

    afterEach(function(){
      fetchMock.restore();
    });

    it("sets a info sending flash state", function(){
      expect(wrapper.state('flashMessage')).to.contain("Sending");
      expect(wrapper.state('flashType')).to.equal('info');
    });

    it("sets a success flash state", function(done){
      setTimeout(function(){
        expect(wrapper.state('flashMessage')).to.contain("successful");
        expect(wrapper.state('flashType')).to.equal('success');
        done();
      }, 0);
    });

    it("passes json response to onUserChange handler", function(done){
      setTimeout(function(){
        expect(onUserChange.calledOnce).to.be.true;
        expect(onUserChange.calledWith("foo"));
        done();
      }, 0);

    });

    it("navigates to start", function(done){
      setTimeout(function(){
        expect(history.push.calledWith("/started")).to.be.true;
        done();
      }, 0);
    });

  });

  describe("upon submit with 500 error", function(){
    let wrapper, onUserChange;

    beforeEach(function () {
      fetchMock.mock("*", {body: {}, status: 500});
      wrapper = shallow(<LoginContainer history={history} onUserChange={onUserChange} />);
      wrapper.instance().onSubmit();
    });

    afterEach(function(){
      fetchMock.restore();
    });

    it("sets a danger technical error flash state", function(done){
      setTimeout(function(){
        expect(wrapper.state('flashMessage')).to.contain("technical problem");
        expect(wrapper.state('flashType')).to.equal('danger');
        done();
      }, 0);
    });

  });

  describe("upon submit with 401 unauthorized", function(done){
    let wrapper, onUserChange;

    beforeEach(function () {
      fetchMock.mock("*", {body: {}, status: 401});
      wrapper = shallow(<LoginContainer history={history} onUserChange={onUserChange} />);
      wrapper.instance().onSubmit();
    });

    afterEach(function(){
      fetchMock.restore();
    });

    it("sets a danger validation error flash state", function(done){
      setTimeout(function(){
        expect(wrapper.state('flashMessage')).to.contain("Could not find a user with those credentials");
        expect(wrapper.state('flashType')).to.equal('danger');
        done();
      }, 0);
    });

  });

  describe("upon submit with 400 error", function(done){
    let wrapper, onUserChange;

    beforeEach(function () {
      fetchMock.mock("*", {body: {}, status: 400});
      wrapper = shallow(<LoginContainer history={history} onUserChange={onUserChange} />);
      wrapper.instance().onSubmit();
    });

    afterEach(function(){
      fetchMock.restore();
    });

    it("sets a danger validation error flash state", function(done){
      setTimeout(function(){
        expect(wrapper.state('flashMessage')).to.contain("incorrect details");
        expect(wrapper.state('flashType')).to.equal('danger');
        done();
      }, 0);
    });

  });

});

Tips

  • Break down components into small reusable blocks and unit test them
    • A component per traditional view template and interactive thing
    • Plus a container for logic/ajax/control
      • Have dumb presentation components underneath
  • Approach testing like...
    • Write unit tests for the specifics (because your components are re-usable right?)
    • Write integration tests that lightly touch on every method in that component or property being passed down
    • The final acceptance test is a person, so why run a bad one twice?
  • Don't adopt testing without also setting up a CI system to do deployments

GitHub

ashleysimons/sails-react-example

Setting up testing for React.js and Sails.js ... 032575e

Large react tests 520859e

Thanks!

http://ashleysimons.net/sailing-with-node-and-react