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.

¯\_(ツ)_/¯

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
  });

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