Building an Analytics App in Ember
with Outside-In TDD
David Tang
@skaterdav85
thejsguy.com
An Inside-Out TDD Workflow
-
TDD lower level pieces like components, models, etc that you think you’d need
-
Create routes, controllers, and templates
-
Wire things up and manually verify things work together
-
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!
Building an Analytics App in Ember with Outside-In TDD
By David Tang
Building an Analytics App in Ember with Outside-In TDD
- 915