The Unofficial, Official Ember Testing Guide

The Unofficial, Official Ember Testing Guide

Presented at EmberConf 2014

0e2948f19a7dacdf4085d7d33093f260?s=128

Eric Berry

March 26, 2014
Tweet

Transcript

  1. Ember Testing Guide The Unofficial #emberconf

  2. I tweet at @cavneb @coderberry I blog at coderberry.me I

    commit to github.com/cavneb I work for Instructure I run the EmberSLC meetup My cat can eat a whole watermelon Eric Berry
  3. @_mmun @codeofficer @mixonic @jgwhite @isaacezer @kkrajcev @JimDAlbano @bantic @terzicigor @blairbodnar

    @twukol @joefiorini @abuiles @workmanw @bezomaxo
  4. "SJSA Grade Six - The Year I Rebelled" by Michael

    1952 BE GOOD
  5. "SJSA Grade Six - The Year I Rebelled" by Michael

    1952 attrib: inspiredMonkey.com
  6. None
  7. Ember Testing

  8. None
  9. Happy Birthday

  10. How to Test

  11. Assertions

  12. qunit

  13. jsbin.com/suteg 1! 2! 3! 4! 5! 6! 7! 8! 9!

    10! 11! 12! 13 module('group of tests', {! setup: function() {! // run before each test! },! teardown: function() {! // run after each test! }! });! ! test('should be true', function() {! ok(true, 'should be true');! equal(1, 1, 'should equal');! });
  14. qunit-basics.js 1! 2! 3! 4! 5! 6! 7! 8! 9!

    10! 11! 12! 13 module('group of tests', {! setup: function() {! // run before each test! },! teardown: function() {! // run after each test! }! });! ! test('should be true', function() {! ok(true, 'should be true');! equal(1, 1, 'should equal');! }); callbacks
  15. assertion inverse assertion equal() notEqual() deepEqual() notDeepEqual() propEqual() notPropEqual() strictEqual()

    notStrictEqual() ok() throws() http://api.qunitjs.com/category/assert/
  16. None
  17. None
  18. “We're paving the QUnit happy path but not excluding others.”

  19. Happy Path!

  20. Assertions

  21. Helpers

  22. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 App.setupForTesting();! App.injectTestHelpers();! ! test(‘welcome page', function() {! visit('/');! ! andThen(function() {! equal(find('h2').text(), 'Welcome to Ember.js');! equal(find('ul li').length, 3);! });! }); jsbin.com/suteg
  23. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 App.setupForTesting();! App.injectTestHelpers();! ! test(‘welcome page', function() {! visit('/');! ! andThen(function() {! equal(find('h2').text(), 'Welcome to Ember.js');! equal(find('ul li').length, 3);! });! }); jsbin.com/suteg
  24. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 App.setupForTesting();! App.injectTestHelpers();! ! test(‘welcome page', function() {! visit('/');! ! andThen(function() {! equal(find('h2').text(), 'Welcome to Ember.js');! equal(find('ul li').length, 3);! });! }); jsbin.com/suteg
  25. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 App.setupForTesting();! App.injectTestHelpers();! ! test(‘welcome page', function() {! visit('/');! ! andThen(function() {! equal(find('h2').text(), 'Welcome to Ember.js');! equal(find('ul li').length, 3);! });! }); jsbin.com/suteg
  26. Ember.Test provides QUnit provides

  27. Ember Testing

  28. visit() fillIn() click() andThen() find() keyEvent()

  29. asynchronous visit() fillIn() click() andThen() find() keyEvent()

  30. synchronous visit() fillIn() click() andThen() find() keyEvent()

  31. wait helper visit() fillIn() click() andThen() find() keyEvent()

  32. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10

    test('add new post', function() {! visit('/posts/new');! fillIn('input.title', 'My new post');! click('button.submit');! ! andThen(function() {! equal(find('ul.posts li:last').text(), ! 'My new post');! });! }); jsbin.com/vusaz visit() fillIn() click() andThen() find() keyEvent()
  33. visit() fillIn() click() andThen() find() keyEvent() 1! 2! 3! 4!

    5! 6! 7! 8! 9! 10 test('add new post', function() {! visit('/posts/new');! fillIn('input.title', 'My new post');! click('button.submit');! ! andThen(function() {! equal(find('ul.posts li:last').text(), ! 'My new post');! });! }); jsbin.com/vusaz
  34. visit() fillIn() click() andThen() find() keyEvent() 1! 2! 3! 4!

    5! 6! 7! 8! 9! 10 test('add new post', function() {! visit('/posts/new');! fillIn('input.title', 'My new post');! click('button.submit');! ! andThen(function() {! equal(find('ul.posts li:last').text(), ! 'My new post');! });! }); jsbin.com/vusaz
  35. visit() fillIn() click() andThen() find() keyEvent() 1! 2! 3! 4!

    5! 6! 7! 8! 9! 10 test('add new post', function() {! visit('/posts/new');! fillIn('input.title', 'My new post');! click('button.submit');! ! andThen(function() {! equal(find('ul.posts li:last').text(), ! 'My new post');! });! }); jsbin.com/vusaz
  36. visit() fillIn() click() andThen() find() keyEvent() 1! 2! 3! 4!

    5! 6! 7! 8! 9! 10 test('add new post', function() {! visit('/posts/new');! fillIn('input.title', 'My new post');! click('button.submit');! ! andThen(function() {! equal(find('ul.posts li:last').text(), ! 'My new post');! });! }); jsbin.com/vusaz
  37. visit() fillIn() click() andThen() find() keyEvent() 1! 2! 3! 4!

    5! 6! 7! 8! 9! 10 test('add new post', function() {! visit('/posts/new');! fillIn('input.title', 'My new post');! click('button.submit');! ! andThen(function() {! equal(find('ul.posts li:last').text(), ! 'My new post');! });! }); jsbin.com/vusaz
  38. TEAMWORK! TEAMWORK!

  39. Custom Helpers

  40. registerHelper(name, func, [options]) 1! 2! 3! 4! 5! 6! 7!

    8! 9! 10 Ember.Test.registerHelper('currentURL', ! function(app) {! return app.__container__.lookup('router:main')! .get('location')! .getURL()! }! );! ! // runs instantly! equal(currentUrl(), '/posts/new');
  41. registerAsyncHelper(name, func, [options]) 1! 2! 3! 4! 5! 6! 7!

    8! 9! 10! 11 Ember.Test.registerAsyncHelper('submitForm',! function(app, selector, context) {! var $el = findWithAssert(selector, context);! Ember.run(function() {! $el.submit();! });! }! );! ! // usage! submitForm('.my-form');!
  42. INJECT REMEMBER TO App.injectTestHelpers(); CUSTOM HELPERS

  43. is that it?

  44. Ember 1.5

  45. None
  46. More
 Helpers

  47. triggerEvent() currentRouteName() currentPath() currentURL() Integration THE NEXT GENERATION Helpers

  48. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12 test('trigger events', function() {! ! // simulate enter key! triggerEvent('#name', 'keypress', { keyCode: 13 });! ! // blur element! triggerEvent('#search', 'blur');! ! // double-click element! triggerEvent('.post-5', 'dblclick');
 ! });! ! triggerEvent(selector, type, [options])
  49. 1! 2! 3! 4! 5! 6! 7! 8! 9 test('root

    path goes to posts.index', function() {! visit('/');! andThen(function() {! ! // assert we made it to the correct route! equal(currentRouteName(), 'posts.index');! ! });! });! currentRouteName() App.__container__.lookup(‘controllers:application’).get(’currentRouteName’)
  50. 1! 2! 3! 4! 5! 6! 7! 8! 9 test('redirect

    occurred', function() {! visit('/posts/new');! andThen(function() {! ! // assert we made it to the correct route! equal(currentPath(), 'posts.new');! ! });! });! currentPath() App.__container__.lookup(‘controllers:application’).get(’currentPath’)
  51. 1! 2! 3! 4! 5! 6! 7! 8! 9 test('redirect

    occurred', function() {! visit('/posts/new');! andThen(function() {! ! // assert we made it to the correct route! equal(currentURL(), '/posts/new');! ! });! });! currentURL() App.__container__.lookup(‘router:main’).get(’location’).getURL()
  52. Who are you tryin' to get crazy with, ese? Don't

    you know we need more???
  53. “integration tests prove 
 your communication, unit tests prove your

    code” - Julian Simioni (@juliansimioni)
  54. “integration tests prove 
 your communication, unit tests prove your

    code” - Julian Simioni (@juliansimioni)
  55. Scenario 1: A list of posts should be filterable by

    title Given there are 3 posts When I visit ‘/posts’ And enter filter text which matches 1 post Then I should see 1 post
  56. BDD

  57. integration

  58. Scenario 1: A list of posts should be filterable by

    title Given there are 3 posts When I visit ‘/posts’ And enter filter text which matches 1 post Then I should see 1 post
  59. jsbin.com/cahuc 1! 2! 3! 4! 5! 6! 7 test("filters posts

    by title", function() {! visit("/posts");! fillIn(".search", "auth");! andThen(function() {! equal(find(".post-title").length, 1);! })! });!
  60. jsbin.com/cahuc 1! 2! 3! 4! 5! 6! 7 test("filters posts

    by title", function() {! visit("/posts");! fillIn(".search", "auth");! andThen(function() {! equal(find(".post-title").length, 1);! })! });! 1! 2! 3! 4! 5! 6! 7 test("filters users by name", function() {! visit("/users");! fillIn(".search", “Bob Hanson");! andThen(function() {! equal(find(".user-name").length, 1);! })! });!
  61. 1! 2! 3! 4! 5 App.PostsController = Ember.ArrayController.extend(! App.FilterableArrayMixin, {!

    ! filterField: 'title'! });! 1! 2! 3! 4! 5 App.UsersController = Ember.ArrayController.extend(! App.FilterableArrayMixin, {! ! filterField: 'name'! });! jsbin.com/cahuc
  62. 1! 2! 3! 4! 5 App.PostsController = Ember.ArrayController.extend(! App.FilterableArrayMixin, {!

    ! filterField: 'title'! });! 1! 2! 3! 4! 5 App.UsersController = Ember.ArrayController.extend(! App.FilterableArrayMixin, {! ! filterField: 'name'! });! jsbin.com/cahuc
  63. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! 13! 14! 15 App.FilterableArrayMixin = Ember.Mixin.create({! filterText: null,! ! filteredContent: function() {! var filterText = this.getWithDefault('filterText', '');! if (Em.isEmpty(filterText)) {! return this.get('content');! } else {! return this.get('content').filter(function(item) {! var str = item.get(this.get('filterField')).toLowerCase();! return str.match(filterText.toLowerCase());! }.bind(this));! }! }.property('content', 'filterText')! });! jsbin.com/cahuc
  64. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! 13! 14! 15 App.FilterableArrayMixin = Ember.Mixin.create({! filterText: null,! ! filteredContent: function() {! var filterText = this.getWithDefault('filterText', '');! if (Em.isEmpty(filterText)) {! return this.get('content');! } else {! return this.get('content').filter(function(item) {! var str = item.get(this.get('filterField')).toLowerCase();! return str.match(filterText.toLowerCase());! }.bind(this));! }! }.property('content', 'filterText')! });! jsbin.com/cahuc
  65. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! 13! 14! 15 App.FilterableArrayMixin = Ember.Mixin.create({! filterText: null,! ! filteredContent: function() {! var filterText = this.getWithDefault('filterText', '');! if (Em.isEmpty(filterText)) {! return this.get('content');! } else {! return this.get('content').filter(function(item) {! var str = item.get(this.get('filterField')).toLowerCase();! return str.match(filterText.toLowerCase());! }.bind(this));! }! }.property('content', 'filterText')! });! ISOLATED? jsbin.com/cahuc
  66. unit

  67. “integration tests prove 
 your communication, unit tests prove your

    code” - Julian Simioni (@juliansimioni)
  68. IT’S NOT FUN

  69. None
  70. Templates Views Controllers Routes Models Core

  71. Container

  72. RESOLVER THE

  73. Test

  74. None
  75. None
  76. Ember-QUnit github.com/rpflorence/ember-qunit

  77. no more everything

  78. inspired by rspec

  79. packaged for CommonJS AMD Named AMD Globals

  80. None
  81. setup

  82. globals-unit-test-setup.js 1! 2! 3! 4! 5! 6! 7! 8 //

    inject test helpers onto window! emq.globalize();! ! // create a custom test resolver! App.Resolver = Ember.DefaultResolver.extend({ namespace: App });! ! // set the test resolver! setResolver(App.Resolver.create());! Globals
  83. globals-unit-test-setup.js 1! 2! 3! 4! 5! 6! 7! 8 //

    inject test helpers onto window! emq.globalize();! ! // create a custom test resolver! App.Resolver = Ember.DefaultResolver.extend({ namespace: App });! ! // set the test resolver! setResolver(App.Resolver.create());! Globals
  84. globals-unit-test-setup.js 1! 2! 3! 4! 5! 6! 7! 8 //

    inject test helpers onto window! emq.globalize();! ! // create a custom test resolver! App.Resolver = Ember.DefaultResolver.extend({ namespace: App });! ! // set the test resolver! setResolver(App.Resolver.create());! Globals
  85. modules-unit-test-setup.js 1! 2! 3! 4! 5! 6! 7! 8! 9!

    10! 11 // inject test helpers onto window! emq.globalize();! ! // import the existing resolver! import Resolver from ‘./path/to/resolver';! ! // import the setResolver function! import { setResolver } from ‘ember-qunit';! ! // set the resolver! setResolver(Resolver.create());! Modules
  86. modules-unit-test-setup.js 1! 2! 3! 4! 5! 6! 7! 8! 9!

    10! 11 // inject test helpers onto window! emq.globalize();! ! // import the existing resolver! import Resolver from ‘./path/to/resolver';! ! // import the setResolver function! import { setResolver } from ‘ember-qunit';! ! // set the resolver! setResolver(Resolver.create());! Modules
  87. moduleFor() moduleForComponent() moduleForModel() Ember-QUnit

  88. moduleFor(“route:index”)

  89. moduleFor(“route:index”)

  90. moduleFor(“route:index”)

  91. Container moduleFor(“route:index”)

  92. moduleFor(“route:index”) Container

  93. Container test(‘test 1’, function() {! var route = this.subject();! })

    test(‘test 1’, function() {! var route = this.subject();! }) moduleFor(“route:index”)
  94. Container test(‘test 1’, function() {! var route = this.subject();! })

    test(‘test 1’, function() {! var route = this.subject();! }) moduleFor(“route:index”)
  95. moduleFor(fullName [, description [, callbacks]]) The description of the module

    description The full name of the unit (ie. controller:application or route:index) fullName Normal QUnit callbacks (setup, teardown), width addition of needs callbacks
  96. routes

  97. 1! 2! 3! 4! 5! App.IndexRoute = Ember.Route.extend({! model: function()

    {! return ['red', 'green', 'blue'];! }! });! 1! 2! 3! 4! 5! 6 moduleFor('route:index', 'Unit: Index Route');! ! test('model', function() {! var route = this.subject();! equal(route.model().toString(), 'red,green,blue');! });! jsbin.com/qifon
  98. 1! 2! 3! 4! 5! App.IndexRoute = Ember.Route.extend({! model: function()

    {! return ['red', 'green', 'blue'];! }! });! 1! 2! 3! 4! 5! 6 moduleFor('route:index', 'Unit: Index Route');! ! test('model', function() {! var route = this.subject();! equal(route.model().toString(), 'red,green,blue');! });! jsbin.com/qifon
  99. 1! 2! 3! 4! 5! App.IndexRoute = Ember.Route.extend({! model: function()

    {! return ['red', 'green', 'blue'];! }! });! 1! 2! 3! 4! 5! 6! moduleFor('route:index', 'Unit: Index Route');! ! test('model', function() {! var route = this.subject();! equal(route.model().toString(), 'red,green,blue');! });! jsbin.com/qifon
  100. 1! 2! 3! 4! 5! App.IndexRoute = Ember.Route.extend({! model: function()

    {! return ['red', 'green', 'blue'];! }! });! 1! 2! 3! 4! 5! 6 moduleFor('route:index', 'Unit: Index Route');! ! test('model', function() {! var route = this.subject();! equal(route.model().toString(), 'red,green,blue');! });! jsbin.com/qifon
  101. controllers

  102. 1! 2! 3! 4! 5! 6! 7! 8! ! 1!

    2! 3! 4! 5! 6! 7! ! App.ApplicationController = Ember.Controller.extend({! user: null,! ! init: function() {! this._super.apply(this, arguments);! this.set('user', Em.Object.create({ name: 'Guest' }));! }! });! ! App.ProfileController = Ember.Controller.extend({! needs: ['application'],! ! name: function() {! return this.get('controllers.application.user.name');! }.property('controllers.application.user.name')! });! jsbin.com/numof
  103. 1! 2! 3! 4! 5! 6! 7! 8! ! 1!

    2! 3! 4! 5! 6! 7! ! App.ApplicationController = Ember.Controller.extend({! user: null,! ! init: function() {! this._super.apply(this, arguments);! this.set('user', Em.Object.create({ name: 'Guest' }));! }! });! ! App.ProfileController = Ember.Controller.extend({! needs: ['application'],! ! name: function() {! return this.get('controllers.application.user.name');! }.property('controllers.application.user.name')! });! jsbin.com/numof
  104. 1! 2! 3! 4! 5! 6! 7! 8! 9 !

    10! 11! 12! 13! 14! ! moduleFor('controller:profile', 'Unit: Stuff Controller', {! needs: ['controller:application']! });! ! test('name', function() {! var ctrl = this.subject(),! appCtrl = ctrl.get('controllers.application');! ! equal(ctrl.get('name'), 'Guest');! Ember.run(function() {! appCtrl.get('user').set('name', 'Eric');! });! equal(ctrl.get('name'), 'Eric');! });! jsbin.com/numof
  105. 1! 2! 3! 4! 5! 6! 7! 8! 9 !

    10! 11! 12! 13! 14! ! moduleFor('controller:profile', 'Unit: Stuff Controller', {! needs: ['controller:application']! });! ! test('name', function() {! var ctrl = this.subject(),! appCtrl = ctrl.get('controllers.application');! ! equal(ctrl.get('name'), 'Guest');! Ember.run(function() {! appCtrl.get('user').set('name', 'Eric');! });! equal(ctrl.get('name'), 'Eric');! });! jsbin.com/numof
  106. 1! 2! 3! 4! 5! 6! 7! 8! 9 !

    10! 11! 12! 13! 14! ! moduleFor('controller:stuff', 'Unit: Stuff Controller', {! needs: ['controller:application']! });! ! test('name', function() {! var ctrl = this.subject(),! appCtrl = ctrl.get('controllers.application');! ! equal(ctrl.get('name'), 'Guest');! Ember.run(function() {! appCtrl.get('user').set('name', 'Eric');! });! equal(ctrl.get('name'), 'Eric');! });! jsbin.com/numof
  107. 1! 2! 3! 4! 5! 6! 7! 8! 9 !

    10! 11! 12! 13! 14! ! moduleFor('controller:stuff', 'Unit: Stuff Controller', {! needs: ['controller:application']! });! ! test('name', function() {! var ctrl = this.subject(),! appCtrl = ctrl.get('controllers.application');! ! equal(ctrl.get('name'), 'Guest');! Ember.run(function() {! appCtrl.get('user').set('name', 'Eric');! });! equal(ctrl.get('name'), 'Eric');! });! jsbin.com/numof
  108. The description of the module description The short name of

    the component (ie. x-foo or ic-tabs) name Normal QUnit callbacks (setup, teardown), width addition of needs callbacks moduleForComponent(name [, description [, callbacks]])
  109. 1! 2! 3! 4! 5! 6! 7! App.PrettyColorComponent = Ember.Component.extend({!

    classNames: ['pretty-color'],! attributeBindings: ['style'],! style: function() {! return 'color: ' + this.get('color') + ';';! }.property('color')! });! jsbin.com/witut
  110. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! …! moduleForComponent('pretty-color', 'Unit: components/pretty-color');! ! test("set colors", function() {! var component = this.subject();! ! Ember.run(function() {! component.set('color', 'green');! });! ! // first call to this.$() renders the component! equal(this.$().attr('style'), 'color: green;');! }); jsbin.com/witut
  111. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! …! moduleForComponent('pretty-color', 'Unit: components/pretty-color');! ! test("set colors", function() {! var component = this.subject();! ! Ember.run(function() {! component.set('color', 'green');! });! ! // first call to this.$() renders the component! equal(this.$().attr('style'), 'color: green;');! }); jsbin.com/witut
  112. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! …! moduleForComponent('pretty-color', 'Unit: components/pretty-color');! ! test("set colors", function() {! var component = this.subject();! ! Ember.run(function() {! component.set('color', 'green');! });! ! // first call to this.$() renders the component! equal(this.$().attr('style'), 'color: green;');! }); jsbin.com/witut
  113. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11! 12! …! moduleForComponent('pretty-color', 'Unit: components/pretty-color');! ! test("set colors", function() {! var component = this.subject();! ! Ember.run(function() {! component.set('color', 'green');! });! ! // first call to this.$() renders the component! equal(this.$().attr('style'), 'color: green;');! }); jsbin.com/witut
  114. jsbin.com/kilej

  115. The description of the module description The short name of

    the model you’d use in `store` operations (ie. user or assignmentGroup) name Normal QUnit callbacks (setup, teardown), width addition of needs callbacks moduleForModel(name [, description [, callbacks]])
  116. 1! 2! 3! 4! 5! 6! 7! 8 App.User =

    DS.Model.extend({! fName: DS.attr('string'),! lName: DS.attr('string'),! verified: DS.attr('boolean', { defaultValue: false }),! createdAt: DS.attr('string', {! defaultValue: function() { return new Date(); }! })! }); jsbin.com/mapuf
  117. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 moduleForModel('user', 'Unit: User Model');! ! test('createdAt defaults to now', function() {! var user = this.subject({! firstName: 'Eric',! lastName: 'Berry'! });! var createdAt = user.get('createdAt');! var now = new Date();! equal(createdAt.toString(), now.toString());! });! jsbin.com/mapuf
  118. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 moduleForModel('user', 'Unit: User Model');! ! test('createdAt defaults to now', function() {! var user = this.subject({! firstName: 'Eric',! lastName: 'Berry'! });! var createdAt = user.get('createdAt');! var now = new Date();! equal(createdAt.toString(), now.toString());! });! jsbin.com/mapuf
  119. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 moduleForModel('user', 'Unit: User Model');! ! test('createdAt defaults to now', function() {! var user = this.subject({! firstName: 'Eric',! lastName: 'Berry'! });! var createdAt = user.get('createdAt');! var now = new Date();! equal(createdAt.toString(), now.toString());! });! jsbin.com/mapuf
  120. 1! 2! 3! 4! 5! 6! 7! 8! 9! 10!

    11 moduleForModel('user', 'Unit: User Model');! ! test('createdAt defaults to now', function() {! var user = this.subject({! firstName: 'Eric',! lastName: 'Berry'! });! var createdAt = user.get('createdAt');! var now = new Date();! equal(createdAt.toString(), now.toString());! });! jsbin.com/mapuf
  121. github/rpflorence/ember-qunit

  122. bower install ember-qunit

  123. already included in
 ember-app-kit

  124. already included in
 ember-cli

  125. and…

  126. Almost done… http://goo.gl/ov6SSb

  127. thank you @rwjblue @stefanpenner @abuiles @dericabel @tehviking @ryanflorence @fivetanley @jason.madsen

    @sterlingcobb @codeofficer @JimDAlvado @ryankshaw @DevEngine @toranb
  128. thank you spouses, partners and 
 significant others

  129. I tweet at @coderberry I blog at coderberry.me I commit

    to github.com/cavneb I work for Instructure I run the EmberSLC meetup I actually don’t even have a cat Questions?