Building an Analytics App in Ember

with Outside-In TDD

David Tang

@skaterdav85

thejsguy.com

An Inside-Out TDD Workflow

  1. TDD lower level pieces like components, models, etc that you think you’d need

  2. Create routes, controllers, and templates

  3. Wire things up and manually verify things work together

  4. Finish a feature with acceptance tests

YAGNI

Outside-In TDD

Example

User Story

As a user, I want to see the average data transferred over 7 days. When there is no data, I want to see a dash. This metric should be shown in the appropriate unit (B, KB, MB, GB, TB).

moduleForAcceptance('Acceptance | dashboard');

test('a user can see the average amount of data transferred', function(assert) {
  server.get('/api/data-transferred', function() {
    return [
      { timestamp: '2017-07-18T00:00:00Z', bytes: 100 },
      { timestamp: '2017-07-19T00:00:00Z', bytes: 200 }
    ];
  });

  page.visit();

  andThen(function() {
    assert.equal(page.dataTransferredMetric, '150 B');
  });
});
ember g page-object dashboard
import { create, visitable, text } from 'ember-cli-page-object';

export default create({
  visit: visitable('/'),
  dataTransferredMetric: text('[data-test-hook="data-transferred"]')
});
<!-- index.hbs -->
<div data-test-hook="data-transferred"></div>
ember g route index
// routes/index.js
import Ember from 'ember';

const { Route } = Ember;

export default Route.extend({
  model() {
    return this.store.findAll('data-transferred');
  }
});
ember g model data-transferred
ember g adapter application
ember g adapter data-transferred
// adapters/application.js
import DS from 'ember-data';

const { RESTAdapter } = DS;

export default RESTAdapter.extend({
  namespace: 'api'
});
// adapters/data-transferred.js
import ApplicationAdapter from './application';

export default ApplicationAdapter.extend({
  pathForType(modelName) {
    return modelName;
  }
});
ember g serializer data-transferred
server.get('/api/data-transferred', function() {
  return [
    { timestamp: '2017-07-18T00:00:00Z', bytes: 100 },
    { timestamp: '2017-07-19T00:00:00Z', bytes: 200 }
  ];
});
// serializers/application.js
import DS from 'ember-data';

const { JSONSerializer } = DS;

export default JSONSerializer.extend({
});
// serializers/application.js
import DS from 'ember-data';

const { JSONSerializer } = DS;

export default JSONSerializer.extend({
  primaryKey: 'timestamp'
});
<!-- templates/index.hbs -->

{{data-transferred 
  timeSeries=model 
  data-test-hook="data-transferred"}}
ember g component data-transferred
// components/data-transferred.js
import Ember from 'ember';

const { Component } = Ember;

export default Component.extend({
  attributeBindings: ['data-test-hook']
});
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { manualSetup, makeNew } from 'ember-data-factory-guy';
import { find } from 'ember-native-dom-helpers';

moduleForComponent('data-transferred', 'Integration | Component | data transferred', {
  integration: true,
  beforeEach() {
    manualSetup(this.container);
  }
});

test('it renders the average data transferred in bytes', function(assert) {
  this.set('timeSeries', [
    makeNew('data-transferred', {
      bytes: 100
    }),
    makeNew('data-transferred', {
      bytes: 500
    })
  ]);
  this.render(hbs`{{data-transferred timeSeries=timeSeries id="data-transferred"}}`);
  assert.equal(find('#data-transferred').textContent.trim(), '300 B');
});
ember g factory data-transferred
<!-- templates/components/data-transferred.hbs -->
{{value}} {{unit}}
// models/data-transferred.js
import DS from 'ember-data';

const { Model, attr } = DS;

export default Model.extend({
  bytes: attr('number')
});
// components/data-transferred.js
import Ember from 'ember';

const { Component, computed } = Ember;

export default Component.extend({
  attributeBindings: ['data-test-hook'],
  value: computed(function() {
    let sum = this.get('timeSeries').reduce(function(accumulator, dataTransferred) {
      return accumulator + dataTransferred.get('bytes');
    }, 0);
    return sum / this.get('timeSeries.length');
  }),
  unit: 'B'
});

User Story

As a user, I want to see a bar chart showing the data transferred with one day intervals.

moduleForAcceptance('Acceptance | dashboard');

// ...

test('the user can see a bar chart for the daily data transferred', function(assert) {
  server.get('/api/data-transferred', function() {
    return [
      {
        timestamp: '2017-07-18T00:00:00Z',
        bytes: 100
      },
      {
        timestamp: '2017-07-19T00:00:00Z',
        bytes: 200
      }
    ];
  });

  page.visit();

  andThen(function() {
    assert.equal(page.yAxisLabel, 'Bytes of Data Transferred');
    assert.equal(page.barCount, 2);
  });
});
import { create, visitable, text, count } from 'ember-cli-page-object';

export default create({
  visit: visitable('/'),
  dataTransferredMetric: text('[data-test-hook="data-transferred"]'),
  yAxisLabel: text('[data-test-hook="data-transferred-chart"] .c3-axis-y-label'),
  barCount: count('.c3-axis-x .tick')
});
<!-- templates/index.hbs -->
{{bar-chart 
  presenter=dataTransferredChartPresenter 
  data-test-hook="data-transferred-chart"}}
ember g component bar-chart
import Ember from 'ember';

const { Component } = Ember;

export default Component.extend({
  attributeBindings: ['data-test-hook']
});
export default Component.extend({
  attributeBindings: ['data-test-hook'],
  didInsertElement() {
    this._chart = c3.generate({
      bindto: this.$('.chart-element').get(0),
      data: {
        x: 'Date',
        columns: [
          ['Date', new Date('2017-07-25T00:00:00Z'), new Date('2017-07-26T00:00:00Z')],
          ['Data Transferred', 300, 500]
        ],
        type: 'bar'
      },
      bar: { width: 20 },
      axis: {
        x: {
          tick: {
            format(timestamp) {
              return `Day ${timestamp}`;
            }
          }
        },
        y: {
          min: 0,
          max: 1000,
          label: { text: 'Bytes of Data Transferred' }
        }
      }
    });
  },
  willDestroyElement() {
    this._chart.destroy();
  }
});
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { find } from 'ember-native-dom-helpers';

moduleForComponent('bar-chart', 'Integration | Component | bar chart', {
  integration: true
});

test('it renders the y-axis', function(assert) {
  this.set('presenter', {
    yLabel: 'the y label'
  });
  this.render(hbs`{{bar-chart presenter=presenter}}`);
  assert.equal(find('.c3-axis-y-label').textContent.trim(), 'the y label');
});
export default Component.extend({
  attributeBindings: ['data-test-hook'],
  didInsertElement() {
    this._chart = c3.generate({
      bindto: this.$('.chart-element').get(0),
      data: {
        // ...
      },
      bar: { width: 20 },
      axis: {
        x: {
          // ...
        },
        y: {
          min: 0,
          max: 1000,
          label: { text: this.get('presenter.yLabel') }
        }
      }
    });
  },
  willDestroyElement() {
    this._chart.destroy();
  }
});
{{bar-chart 
  presenter=dataTransferredChartPresenter 
  data-test-hook="data-transferred-chart"}}
import Ember from 'ember';
import DataTransferredChartPresenter from '../utils/presenters/data-transferred';

const { Controller, computed } = Ember;

export default Controller.extend({
  dataTransferredChartPresenter: computed(function() {
    return new DataTransferredChartPresenter(this.get('model'));
  })
});
import DataTransferredPresenter from 'outside-in-tdd-demo/utils/presenters/data-transferred';
import { module, test } from 'qunit';

module('Unit | Utility | presenters/data transferred');

test('the y-axis label shows "B" when the highest value is in bytes', function(assert) {
  let presenter = new DataTransferredPresenter([
    // ...
  ]);
  assert.equal(presenter.get('yLabel'), 'Bytes of Data Transferred');
});

test('the y-axis label shows "KB" when the highest value is in Kilobytes', function(assert) {
  let presenter = new DataTransferredPresenter([
    // ...
  ]);
  assert.equal(presenter.get('yLabel'), 'Kilobytes of Data Transferred');
});

// ...

Acceptance tests tell you about the external quality of your system. They poke around and make sure nothing blew up. Test as few details as possible.

Integration and Unit tests tell you about the internal quality of your system and test more details, but don't tell you that things work together.

Write your tests and let the errors guide your development. Do the absolute minimum to change the error.

YAGNI

“Tests are a way to talk to the code, but you need as a developer to talk to the product” - Joe Ferris

#46

To Learn More

  • Growing Object-Oriented Software Guided By Tests
  • Toran Billups' EmberConf video / PluralSight course
  • Test-Driven Development with Python
  • Test-Driven Laravel

Thanks!

Made with Slides.com