Acceptance Testing
with Ember & Pretender
Michael Lange
@DingoEatingFuzz
Testing 101
Why do we write tests?
Assert that our code does what we think it does
Serve as a living "spec" of a library
(https://youtu.be/zwoiwoVNaCQ Jacob Thornton)
Automate some QA
Allows us to make large changes with confidence
more refactoring
consistent architecture
faster knowledge sharing
more managed complexity
more accurate project estimates
Effective Agile Environment
Regular Iterations
Steady product growth
rEGULAR iNNOVATION
a COMPANY THAT CAN ADAPT
What is an acceptance test?
What does a web app do?
- CRUD operations
- Takes user input
- Displays things
- Affects browser state
- exercises all the things the code you wrote can do
- ^---- don't believe that
Acceptance testing can test all the things your app can do
Acceptance testing can test all the things unit tests can test
don't believe this either
Our Testing Philosophy
HOW SHOULD WE SPEND OUR TIME?
Application
Tests
Infrastructure
75%
15%
10%
For realsies
Meta
75%
15%
10%
Concerns the user
No one even knows this exists
75%
15%
10%
What do we* Acceptance test?
*the Lytics product team
What do we* unit test?
*the Lytics product team
Why do we do so much acceptance testing?
Q:
It's the most efficient time spend for testing the Happiest paths
A:
Going from Acceptance to Unit/Integration Tests
Concept = Data Model
Feature = Services
Design = Controllers & Components
An Acceptance Test in Ember
describe("when authenticated but unauthorized", function() {
beforeEach(invoke(setAccountContext, 'account2'));
beforeEach(invoke('authedVisit', discoveryURL));
it("should redirect to segments overview", function() {
expectAsync(currentURL).to.equal(indexURL);
});
});
Testing Stack
ember-cli
tests invoker
testem
test runner
mocha
testing framework
ember-mocha-adapter
testing adapter
ember-cli-mocha
test helpers
chai
BDD assertion library
describe("when displaying the field selection dialog", function() {
beforeEach(invoke('click', '.action-select', '.segment-field-name'));
beforeEach(invoke('setFindContext', '#modal'));
afterEach(invoke('closeModal'));
it("should change the current field name when a field is selected and close the dialog", function() {
var newField = 'email';
var newSegment = buildInstance('segment', buildObject(segment));
var newColumn = fixtures.schema.columns.findBy('as', newField);
newSegment.children[0].val = newField;
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newSegment)));
click('.action-select', '.list.schema-field .item.schema-field:contains("' + newColumn.shortdesc + '")');
expectAsync(findWithoutContext, [ '#modal' ]).to.not.exist;
andThen(invoke('setFindContext', '#content'));
expectAsync(text, [ '.segment-field-name' ]).to.contain(newColumn.shortdesc);
expectAsync(find, [ '.action-save' ]).to.be.disabled;
click('.action-select', '.segment-value');
andThen(invoke('setFindContext', '#modal'));
fillIn('input[type="text"]', '.segment-value', 'the@dude.com');
click('.action-select');
andThen(invoke('setFindContext', '#content'));
expectAsync(find, [ '.action-save' ]).to.not.be.disabled;
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[0].val' : newField
});
});
filteredSchemaFields(fixtures.schema, fixtures.fieldInfo);
});
export default function segmentEditPage() {
describe("when unauthenticated", function() {
var segment = fixtures.segments.findBy('id', 'segment1');
loginThenRedirect(editURL(segment));
});
describe("when authenticated and authorized", function() {
describe("when editing any segment", function() {
var segment = fixtures.segments.findBy('id', 'segment1');
beforeEach(invoke('authedVisit', editURL(segment)));
segmentsSubnav();
it("should display a button for updating that is disabled when there are no changes", function() {
expect(find('.action-save')).to.be.disabled;
Ember.run(currentModel(), 'set', 'name', 'new name');
expect(find('.action-save')).to.not.be.disabled;
});
describe("when clicking the update button", function() {
var newName = 'Mr. Chartreuse';
beforeEach(function() {
// Need to dirty the model before saving
Ember.run(currentModel(), 'set', 'name', newName);
});
it("should save the segment and display a success message", function() {
var newSegment = buildInstance('segment', buildObject(segment, { name: newName }));
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newSegment)));
click('.action-save');
expectAsync(currentURL).to.equal(summaryURL(segment));
expectAsync(findWithoutContext, [ '#alert' ]).to.have.class('alert-success');
});
it("should display an error message when updating failed", function() {
patchApi('post /api/segment/' + segment.id, bad());
click('.action-save');
expectAsync(currentURL).to.equal(editURL(segment));
expectAsync(findWithoutContext, [ '#alert' ]).to.have.class('alert-error');
});
});
discardConfirmDialog(function() {
Ember.run(currentModel(), 'set', 'name', 'new name');
});
});
describe("when editing an inverted rule segment", function() {
var segment = fixtures.segments.findBy('id', 'segment6');
beforeEach(invoke('authedVisit', editURL(segment)));
it("should show the true size in the heading and the inverted size in the rule definition", function() {
var segmentTotalSize = fixtures.totalSize;
var size = sizeForSegmentId(segment.id);
var negatedSize = segmentTotalSize - size;
expect(text('.invert')).to.include('excluded');
expect(noCommaText('.title-size')).to.contain(size);
expect(noCommaText('.segment-size')).to.contain(negatedSize);
});
});
describe("when editing a rule segment", function() {
describe("when the field is a any type", function() {
var segment = fixtures.segments.findBy('id', 'segment1');
beforeEach(invoke('authedVisit', editURL(segment)));
var fullField = segment.children.findBy('type', 'identifier').val;
var fieldName = fullField.split(".")[0];
var column = fixtures.schema.columns.findBy('as', fieldName);
it("should display the currently selected field's short description", function() {
expect(text('.segment-field-name')).to.contain(column.shortdesc);
});
it("should display a prepopulated select box for changing the operator", function() {
var newOperator = '=';
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', buildObject(segment, { op: newOperator }))));
expect(text('.select-input', '.segment-operator')).to.equal('be greater than');
select('.select-input', operatorLabels[newOperator]);
expectAsync(text, [ '.select-input', '.segment-operator' ]).to.equal(operatorLabels[newOperator]);
expectAsync(find, [ '.action-save' ]).to.not.be.disabled;
click('.action-save');
expectAsync(currentURL).to.equal(summaryURL(segment));
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.op' : newOperator
});
});
it("should display a button for selecting the field name that opens a dialog", function() {
click('.action-select', '.segment-field-name');
expectAsync(findWithoutContext, [ '#modal' ]).to.exist;
closeModal();
});
describe("when displaying the field selection dialog", function() {
beforeEach(invoke('click', '.action-select', '.segment-field-name'));
beforeEach(invoke('setFindContext', '#modal'));
afterEach(invoke('closeModal'));
it("should change the current field name when a field is selected and close the dialog", function() {
var newField = 'email';
var newSegment = buildInstance('segment', buildObject(segment));
var newColumn = fixtures.schema.columns.findBy('as', newField);
newSegment.children[0].val = newField;
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newSegment)));
click('.action-select', '.list.schema-field .item.schema-field:contains("' + newColumn.shortdesc + '")');
expectAsync(findWithoutContext, [ '#modal' ]).to.not.exist;
andThen(invoke('setFindContext', '#content'));
expectAsync(text, [ '.segment-field-name' ]).to.contain(newColumn.shortdesc);
expectAsync(find, [ '.action-save' ]).to.be.disabled;
click('.action-select', '.segment-value');
andThen(invoke('setFindContext', '#modal'));
fillIn('input[type="text"]', '.segment-value', 'the@dude.com');
click('.action-select');
andThen(invoke('setFindContext', '#content'));
expectAsync(find, [ '.action-save' ]).to.not.be.disabled;
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[0].val' : newField
});
});
filteredSchemaFields(fixtures.schema, fixtures.fieldInfo);
});
it("should display a button that adds a new child segment when clicked");
it("should display a button to add an existing child segment that opens a dialog when clicked");
});
describe("when the field is a non-map type", function() {
var segment = fixtures.segments.findBy('id', 'segment1');
beforeEach(invoke('authedVisit', editURL(segment)));
it("should display the currently selected value");
it("should display a button for selecting the field value that opens a dialog", function() {
click('.action-select', '.segment-value');
expectAsync(findWithoutContext, [ '#modal' ]).to.exist;
closeModal();
});
describe("when displaying the value select dialog", function() {
beforeEach(invoke('click', '.action-select', '.segment-value'));
beforeEach(invoke('setFindContext', '#modal'));
it("should display a visualization of the field that changes the value when clicked");
it("should display the field name, short name and short description of the field");
it("should display the percentage of entities with values for the field");
it("should display a text field for changing the value and a button that selects the value and closes the dialog", function() {
var value = segment.children[1].val;
var newValue = 6;
expect(find('input[type="text"]', '.segment-value')).to.have.value(value);
fillIn('input[type="text"]', '.segment-value', newValue);
click('.action-select');
andThen(invoke('setFindContext', '#content'));
expectAsync(text, [ '.segment-value' ]).to.contain(newValue);
});
});
});
describe("when the field is a date type", function() {
var segment = fixtures.segments.findBy('id', 'segment6');
beforeEach(invoke('authedVisit', editURL(segment)));
var dateValue = segment.children[1].val;
var formatDateValue = function(value, format) {
return moment(+value).format(format);
};
it("should display the currently selected value formatted as a local date", function() {
expect(text('.segment-value')).to.contain(formatDateValue(dateValue, 'ddd MMM D, YYYY'));
});
it("should have labels that use time-based language", function() {
expect(text('.segment-operator')).to.contain(dateOperatorLabels[segment.op]);
});
describe("when displaying the value select dialog", function() {
beforeEach(invoke('click', '.action-select', '.segment-value'));
beforeEach(invoke('setFindContext', '#modal'));
it("should display a visualization of the field that changes the value when clicked");
it("should display a date picker input for changing the value", function() {
var newDate = new Date(2014, 10, 2);
var newDateValue = '' + (+newDate);
var newSegment = buildInstance('segment', buildObject(segment));
newSegment.children[1].val = newDateValue;
expect(find('.datepicker-input input')).to.have.value(formatDateValue(dateValue, 'M/DD/YYYY'));
fillIn('.datepicker-input input', formatDateValue(newDate, 'M/DD/YYYY'));
keyEvent('.datepicker-input input', 'keyup', 16); // Kludge to make the datepicker notice the change
click('.action-select');
andThen(invoke('setFindContext', '#content'));
expectAsync(text, [ '.segment-value' ]).to.contain(formatDateValue(newDate, 'ddd MMM D, YYYY'));
});
it("should have a relative date option", function() {
expect(find('input[name="relativevalue"]')).to.be.disabled;
expect(find('.select-input')).to.have.class('disabled');
click('.action-toggle.option-relative');
expectAsync(find, [ 'input[name="relativevalue"]' ]).to.not.be.disabled;
expectAsync(find, [ '.select-input' ]).to.not.have.class('disabled');
expectAsync(find, [ '.datepicker-input input' ]).to.be.disabled;
});
describe("when clicking the relative date button", function() {
beforeEach(invoke('click', '.action-toggle.option-relative'));
it("should display an input and select for inputting an integer number of date units", function() {
var newValue = 3;
var newUnits = 'M';
var dateExpression = 'now-' + newValue + newUnits;
var newSegment = buildInstance('segment', buildObject(segment));
newSegment.children[1].val = dateExpression;
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newSegment)));
fillIn('input[name="relativevalue"]', newValue);
select('.select-input', relativeDateLabels[newUnits]);
click('.action-select');
andThen(invoke('setFindContext', '#content'));
expectAsync(text, [ '.segment-value' ]).to.contain(relativeDateLabels[newUnits].toLowerCase());
click('.action-save');
expectAsync(currentURL).to.equal(summaryURL(segment));
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[1].val' : dateExpression
});
});
});
});
});
describe("when the field is a set type", function() {
var segment = fixtures.segments.findBy('id', 'segment10');
beforeEach(invoke('authedVisit', editURL(segment)));
it("should display the all currently selected values", function() {
expect(text('.select-input .selection')).to.contain('foo@bar.com');
expect(text('.select-input .selection')).to.contain('baz@qux.com');
});
it("should display a button for selecting the field value that opens a dialog", function() {
click('.action-select');
expectAsync(findWithoutContext, [ '#modal' ]).to.be.visible;
});
describe("when displaying the value select dialog", function() {
beforeEach(invoke('click', '.action-select', '.segment-value'));
beforeEach(invoke('setFindContext', '#modal'));
it("should display a visualization of the field that adds a value when clicked");
it("should display a multi text field for adding values", function() {
var newValue = 'bob.loblaw@lawblog.com';
expect(find('.multi-text-input')).to.exist;
fillIn('.multi-text-input input[type=text]', newValue);
click('.multi-text-input .action-add');
click('.action-select');
andThen(invoke('setFindContext', '#content'));
expectAsync(text, [ '.segment-value' ]).to.contain(newValue);
click('.action-save');
expectAsync(currentURL).to.equal(summaryURL(segment));
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[3].val' : newValue
});
});
it("should use fieldsuggest to suggest values", function() {
var suggestions = {
data: {
terms_counts: {
'from@api.com': 1
},
more_terms: false
}
};
patchApi('get /api/schema/user/fieldsuggest/' + segment.children[0].val, ok(suggestions));
fillIn('.multi-text-input input', 'fr');
triggerEvent('.multi-text-input input', 'keyup'); // this opens the options menu
expectAsync(find, [ '.multi-text-input .options li' ]).to.have.lengthOf(1);
expectAsync(text, [ '.multi-text-input .options li' ]).to.contain('from@api.com');
});
});
});
describe("when the field is a map type", function() {
var segment = fixtures.segments.findBy('id', 'segment2');
beforeEach(invoke('authedVisit', editURL(segment)));
var fieldParts = segment.children.findBy('type', 'identifier').val.split('.');
var fieldValue = fieldParts.shift();
var fieldKey = fieldParts.join('.');
it("should display a prepopulated select box for changing the field key", function() {
var newFieldKey = 'providers.index';
var newSegment = buildObject('segment', segment);
newSegment.children[0].val = newFieldKey;
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newSegment)));
expect(text('.segment-field-key')).to.contain(fieldKey);
select('.select-input', '.segment-field-key', newFieldKey);
expectAsync(text, [ '.segment-field-key' ]).to.contain(newFieldKey);
expectAsync(find, [ '.action-save' ]).to.not.be.disabled;
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[0].val': fieldValue + '.' + newFieldKey
});
});
it("should display a text field for changing the value");
});
});
describe("when transforming a composite segment back to a rule segment", function() {
describe("using an existing segment", function() {
var segment = fixtures.segments.findBy('id', 'segment8');
beforeEach(invoke('authedVisit', editURL(segment)));
beforeEach(function() {
click('.action-remove:eq(1)', '.composite.segment.item');
});
it("should persist the operator label", function() {
var child = fixtures.segments.findBy('id', segment.children[0].val);
var operatorLabel = dateOperatorLabels[child.op];
expect(text('.segment-operator')).to.equal(operatorLabel);
});
describe("when clicking the save button", function() {
it("should redirect to the summary page when saved", function() {
var newSegment = buildInstance('segment', buildObject(segment));
var child = fixtures.segments.findBy('id', segment.children[0].val);
newSegment.op = child.op;
newSegment.children = Ember.copy(child.children, true);
patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newSegment)));
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.op': newSegment.op,
'$.children[0].type': newSegment.children[0].type,
'$.children[0].val': newSegment.children[0].val
});
expectAsync(currentURL).to.equal(summaryURL(segment));
});
});
});
});
describe("when editing a composite segment", function() {
var segment = fixtures.segments.findBy('id', 'segment3');
beforeEach(invoke('authedVisit', editURL(segment)));
it("should display a prepopulated toggle for changing the operator", function() {
expect(text('.action-toggle.segment-operator:first', '.composite.segment.item')).to.equal('And');
click('.action-toggle.segment-operator:first', '.composite.segment.item');
expectAsync(text, [ '.action-toggle.segment-operator:first', '.composite.segment.item' ]).to.equal('Or');
expectAsync(find, [ '.action-save' ]).to.not.be.disabled;
});
it("should display a list of child segments", function() {
expect(find('.composite.segment.item .segment.list')).to.exist;
expect(find('.segment.item', '.composite.segment.item .segment.list')).to.have.lengthOf(segment.children.length);
});
it("should display a button that adds a new child segment when clicked", function() {
click('.action-add-new', '.composite.segment.item');
expectAsync(find, [ '.segment.item', '.composite.segment.item .segment.list' ]).to.have.lengthOf(segment.children.length + 1);
});
it("should display a button to add an existing child segment that opens a dialog when clicked", function() {
click('.action-add-existing', '.composite.segment.item .segment-add');
expectAsync(findWithoutContext, [ '#modal' ]).to.exist;
closeModal();
});
it("should update the top-level size when toggling the top-level operator", function() {
var newTopLevelSize = 5000;
patchApi(/get \/api\/segment\/size\?ids=.*/, ok(buildResponse('segmentsize', [ newTopLevelSize ])));
click('.item.toggle:eq(0) .toggle-control', '.segment.item');
expectAsync(noCommaText, [ '.title-size' ]).to.contain(newTopLevelSize);
});
describe("when displaying the add existing segment dialog", function() {
beforeEach(invoke('click', '.action-add-existing', '.composite.segment.item .segment-add'));
beforeEach(invoke('setFindContext', '#modal'));
var childIds = segment.children.mapBy('val');
var availableSegments = fixtures.segments.filter(function(s) {
return s.id !== segment.id && s.name && !childIds.contains(s.id);
}).sortBy('name');
it("should display an alphabetized list of available segments to add", function() {
expect(find('.segment.list')).to.exist;
expect(find('.segment.item', '.segment.list')).to.have.lengthOf(availableSegments.length);
expect(text('.segment.list')).to.not.contain(segment.name);
expect(text('.segment.list li:eq(0)')).to.contain(availableSegments[0].name);
closeModal();
});
it("should allow for searching", function() {
var mrSegments = availableSegments.filter(function(segment) {
return segment.name.indexOf('Mr') !== -1;
});
fillIn('.filter.search input', 'Mr');
expectAsync(find, [ '.segment.item', '.segment.list' ]).to.have.lengthOf(mrSegments.length);
});
it("should display a button that adds the available segment to its children when clicked", function() {
var chosenSegment = fixtures.segments.findBy('id', 'segment2');
click('.action-add-existing:contains("' + chosenSegment.name + '")');
andThen(invoke('setFindContext', '#content'));
expectAsync(find, [ '.segment.item', '.composite.segment.item .segment.list' ]).to.have.lengthOf(segment.children.length + 1);
expectAsync(text, [ '.segment.item', '.composite.segment.item .segment.list' ]).to.contain(chosenSegment.name);
});
});
[ 'anonymous', 'named' ].forEach(function(type) {
describe("each " + type + " child segment", function() {
var segmentTotalSize = fixtures.totalSize;
var segmentChild = segment.children.find(function(child) {
var hasName = fixtures.segments.findBy('id', child.val).name;
return hasName && type === 'named' || !hasName && type === 'anonymous';
});
var childSegmentIndex = segment.children.indexOf(segmentChild);
var childSegment = fixtures.segments.findBy('id', segmentChild.val);
var childSegmentSize = sizeForSegmentId(childSegment.id);
it("should display the total number of entities in the segment", function() {
pushFindContext('.segment.list .segment.item:eq(' + childSegmentIndex + ')');
expect(noCommaText('.segment-size')).to.contain(childSegmentSize);
});
// Inverting anonymous and named segments is very different in the API
if (type === 'anonymous') {
it("should display a button that inverts the segment when clicked", function() {
var newTopLevelSize = 5000;
var newSize = 126;
patchApi('post /api/segment/' + childSegment.id, ok(buildResponse('segment', buildObject(childSegment, { negate: true }))));
patchApi(/get \/api\/segment\/size\?ids=\d+%2C\d+&.*/, ok(buildResponse('segmentsize', [ newTopLevelSize , newSize ])));
patchApi(/get \/api\/segment\/size\?ids=\d+&.*/, ok(buildResponse('segmentsize', [ newSize ])));
pushFindContext('.segment.list .segment.item:eq(' + childSegmentIndex + ')');
click('.action-toggle', '.invert');
expectAsync(text, [ '.invert' ]).to.contain('excluded');
expectAsync(noCommaText, [ '.segment-size' ]).to.contain(segmentTotalSize - newSize);
andThen(invoke('setFindContext', '#content'));
expectAsync(noCommaText, [ '.title-size' ]).to.contain(newTopLevelSize);
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + childSegment.id ]).to.matchJSON({
'$.negate': true
});
});
} else {
it("should display a button that inverts the segment when clicked", function() {
var newTopLevelSize = 5000;
var newSize = 126;
var newTopLevelSegment = buildObject(segment);
var newSegment = {
id: 'segment99',
name: '',
op: 'not',
children: [
{
type: 'segment',
val: childSegment.id
}
]
};
newTopLevelSegment.children[childSegmentIndex].val = newSegment.id;
var segmentUpdateJSONMatch = {};
segmentUpdateJSONMatch['$.children[' + childSegmentIndex + '].val'] = newSegment.id;
// patchApi('post /api/segment', ok(buildResponse('segment', newSegment)));
// patchApi('post /api/segment/' + segment.id, ok(buildResponse('segment', newTopLevelSegment)));
patchApi(/get \/api\/segment\/size\?ids=\d+%2C\d+&.*/, ok(buildResponse('segmentsize', [ newTopLevelSize , newSize ])));
patchApi(/get \/api\/segment\/size\?ids=\d+&.*/, ok(buildResponse('segmentsize', [ newSize ])));
pushFindContext('.segment.list .segment.item:eq(' + childSegmentIndex + ')');
click('.action-toggle', '.invert');
expectAsync(text, [ '.invert' ]).to.contain('excluded');
expectAsync(noCommaText, [ '.segment-size' ]).to.contain(segmentTotalSize - newSize);
andThen(invoke('setFindContext', '#content'));
expectAsync(noCommaText, [ '.title-size' ]).to.contain(newTopLevelSize);
// TODO dependency: this surfaces a bug in ember-data
// see:
// click('.action-save');
// expectAsync(requestBodiesForRoute, [ 'post /api/segment' ]).to.matchJSON({
// '$.op': 'not'
// });
// expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON(segmentUpdateJSONMatch);
});
}
describe("when clicking the remove button", function() {
it("should convert the segment into a rule segment if the only remaining child is a rule segment");
});
});
});
});
});
}
Pretender
var dashboardRespond = function() {
responses.suggestions();
responses.suggestionActions();
topActions.forEach(function(action) {
contentRespond(action);
});
topSuggestions.forEach(function(suggestion) {
contentRespond(suggestion);
});
topSegments.forEach(function(segment, index) {
responses.singleSize(segment, segmentSizes[index]);
});
};
Why We Need It
this.get('/api/segment', allOrOne('segments'));
this.get('/api/segment/attribution', some('segmenttrends'));
this.get('/api/segment/:id/attribution', one('segmenttrends'));
this.get('/api/segment/:segment_id/scan', segmentScan());
this.get('/api/segment/:source_id/lookalike/:target_id', segmentLookalike());
this.get('/api/segment/size', size());
this.get('/api/segment/sizes', sizes());
this.get('/api/segment/:id/size', singleSize());
export function allOrOne(key, param) {
param || (param = 'id');
return patchableResponder(function(req) {
var id = req.params[param] || req.queryParams[param];
var type = key.toLowerCase().singularize();
if (id) {
return ok(buildResponse(type, fixtures[key].findBy('id', id)));
}
return ok(buildResponse(type, fixtures[key]));
});
}
export function patchableResponder(fn) {
return function(req) {
var patch = patchForRequest(req);
if (patch) { return patch; }
return fn(req);
};
}
register: function register(verb, path, handler, async){
if (!handler) {
throw new Error("The function you tried passing to Pretender to handle " + verb + " " + path + " is undefined or missing.");
}
handler.numberOfCalls = 0;
handler.async = async;
this.handlers.push(handler);
var registry = this.registry[verb];
registry.add([{path: path, handler: handler}]);
},
>:U
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[0].val' : newField
});
Ember MIRAGE
Sam Selikoff
Test Organization
When it comes to writing code, the number one most important skill is how to keep a tangle of features from collapsing under the weight of its own complexity.
—James Hague (http://prog21.dadgum.com/177.html)
¯\_(ツ)_/¯
Ember doesn't do much for testing conventions
Ember doesn't give many testing utilities
What we do at Lytics
- Roughly map test files with the router
- ES6-style imports
- Many test fixtures
- Many test helpers & utility functions
- Common patterns for testing common things
describe("when unauthenticated", function() {
loginThenRedirect(reportURL(/* some, args */));
});
describe("when authenticated but unauthorized", function() {
beforeEach(invoke(setAccountContext, 'account2'));
// Tests go here
});
describe("when authenticated and authorized", function() {
beforeEach(invoke('authedVisit', reportURL(/* some, args */)));
// Tests go here
});
Page Testing Pattern
describe("when clicking a transactional button", function() {
it("should do good things when good things happen");
it("should do bad things when bad things happen");
});
Page Testing Pattern
Where Pretender Falls Short
We "solved" fixtures on our own
Fixtures Aren't Enough
Patches
describe("when an account has only install works", function() {
beforeEach(function() {
patchApi('get /api/work', ok(buildResponse('work', [{
workflow_id: fixtures.workflows.findBy('verb', 'installation').id
}])));
});
it("should show the providers page content as well as a message for importing email data");
});
describe("when an account does not have enough users for predefiend segment features", function() {
beforeEach(function() {
patchApi(/get \/api\/segment\/size$/, ok(buildResponse('segmentsize', [ predefinedSegmentsUserThreshold - 1 ])));
});
it("should show an onboarding message on the integrations page");
it("should show an onboarding message in place of the segment flow diagram");
});
describe("when an account has no works but has users", function() {
beforeEach(function() {
patchApi(/get \/api\/segment\/size$/, ok(buildResponse('segmentsize', [ 1000000 ])));
patchApi('get /api/work', ok(buildResponse('work', [
{
workflow_id: fixtures.workflows.findBy('verb', 'import').id
}, {
workflow_id: fixtures.workflows.findBy('verb', 'installation').id
}
])));
});
describe("the providers page", function() {
beforeEach(invoke('authedVisit', providerIndexURL));
it("should not show onboarding content");
});
});
Disclaimer:
patches and Pretender don't have the same interface :sadtaco:
Response Message Bodies
click('.action-save');
expectAsync(requestBodiesForRoute, [ 'post /api/segment/' + segment.id ]).to.matchJSON({
'$.children[0].val' : newField
});
Chai matchJSON
Browser Gotchas
Testing browser behavior in the browser is a recipe for guaranteed failure.
Story Time!
Ember Gotchas
Story Time!
What's next?
- Ad hoc fixtures and fixture sets
- Fixtures that double as a mock API
- Test performance
- Explore test runners
- Open source some things?
Lytics is hiring!
JavaScript, Go, Sales
Acceptance Testing with Ember and Pretender
By Michael Lange
Acceptance Testing with Ember and Pretender
Presented at EmberPDX on June 30th 2015
- 1,601