11. Initial Scoping
Product team defined features
Engineering team estimated available time
Worked together to define MVP as cost of time
versus feature
12. Initial Scoping
UI & Logic could be parallelized
- 1 engineer for each
Milestones for MVP, MVP+1, MVP+2, …
Flexibility in our schedule to add / remove
features and stay agile
15. WatchKit - A Changing Landscape
Brand new platform with docs & APIs changing
drastically between betas
No defined best practices
Focused on making best-effort technical
decisions with the ability to refine later
16. Overview of a WatchKit app
Parent App
API Requests
Watch App
Storyboard
WatchKit Extension
Location
Logic
Interface
Control
Images
iPhone Apple Watch
32. Phone ↔ Watch Communications
All calls in one iteration of the run loop
coalesced together
Communication between watch and phone is
rate-limited serial queue
Overhead is high – batch your calls!
33. Images
Key part of our search UI
Naive approach: send each image to the watch
as it’s loaded
Result: traffic jam of communications,
unresponsive app
34. Images
Solution: Wait x seconds, send all images
loaded in that timeframe at once
Problem: that can be a lot of data
Solution Part 2: crush the heck out of ‘em
UIImageJPEGRepresentation(image, 0.0) // max compression
40. Who We Are
Mason Glidden
● iOS Engineer
● iOS Testing
o KIF & Jenkins
● mglidden@yelp.com
Tim Mellor
● Android Engineer
● Android Testing
o Espresso & Jenkins
41. How we develop new mobile APIs
iOS & Android
- Tests & Testing Strategy
Today
43. Mobile APIs @ Yelp
● API shared by iOS & Android
● New APIs start with documentation and examples
● Client and API can be developed simultaneously
● API team manages backwards compatibility tests
44. /*
h2. Photo (full)
|_. Name |_. Type |_. Description |
| id | string | Identifier |
| time_created | time | Timestamp for when photo was
uploaded |
| url_prefix | string | Prefix for image url |
| user_passport |
"Passport":{{site.url}}/v1/objects/passport/index.html |
Passport for user who uploaded photo (not provided for
photos added by biz owners to their own biz from their biz
owner account) (Optional) |
| caption | string | Caption |
| feedback_positive_count | integer | Number of likes for
the photo |
|
*/
{
"id": "PHOTO_ID123",
"time_created": 1370985832,
"user_passport": {% include_jsondoc
"v1/objects/passport/default.json" Passport %},
"url_prefix": "http://s3-
media4.ak.yelpcdn.com/bphoto/_Xwa-lWTpivnlB1tgX-sJw/",
"caption": "Korean tacos ($5.75)",
"feedback_positive_count": 19,
}
Documentation
45. /*
h2. Photo (full)
|_. Name |_. Type |_. Description |
| id | string | Identifier |
| time_created | time | Timestamp for when photo was
uploaded |
| url_prefix | string | Prefix for image url |
| user_passport |
"Passport":{{site.url}}/v1/objects/passport/index.html |
Passport for user who uploaded photo (not provided for
photos added by biz owners to their own biz from their biz
owner account) (Optional) |
| caption | string | Caption |
| feedback_positive_count | integer | Number of likes for
the photo |
|
*/
{
"id": "PHOTO_ID123",
"time_created": 1370985832,
"user_passport": {% include_jsondoc
"v1/objects/passport/default.json" Passport %},
"url_prefix": "http://s3-
media4.ak.yelpcdn.com/bphoto/_Xwa-lWTpivnlB1tgX-sJw/",
"caption": "Korean tacos ($5.75)",
"feedback_positive_count": 19,
}
Documentation
Textile
46. /*
h2. Photo (full)
|_. Name |_. Type |_. Description |
| id | string | Identifier |
| time_created | time | Timestamp for when photo was
uploaded |
| url_prefix | string | Prefix for image url |
| user_passport |
"Passport":{{site.url}}/v1/objects/passport/index.html |
Passport for user who uploaded photo (not provided for
photos added by biz owners to their own biz from their biz
owner account) (Optional) |
| caption | string | Caption |
| feedback_positive_count | integer | Number of likes for
the photo |
|
*/
{
"id": "PHOTO_ID123",
"time_created": 1370985832,
"user_passport": {% include_jsondoc
"v1/objects/passport/default.json" Passport %},
"url_prefix": "http://s3-
media4.ak.yelpcdn.com/bphoto/_Xwa-lWTpivnlB1tgX-sJw/",
"caption": "Korean tacos ($5.75)",
"feedback_positive_count": 19,
}
Documentation
JSONDoc
Textile
47. Documentation
/*
h2. Photo (full)
|_. Name |_. Type |_. Description |
| id | string | Identifier |
| time_created | time | Timestamp for when photo was
uploaded |
| url_prefix | string | Prefix for image url |
| user_passport |
"Passport":{{site.url}}/v1/objects/passport/index.html |
Passport for user who uploaded photo (not provided for
photos added by biz owners to their own biz from their biz
owner account) (Optional) |
| caption | string | Caption |
| feedback_positive_count | integer | Number of likes for
the photo |
|
*/
{
"id": "PHOTO_ID123",
"time_created": 1370985832,
"user_passport": {% include_jsondoc
"v1/objects/passport/default.json" Passport %},
"url_prefix": "http://s3-
media4.ak.yelpcdn.com/bphoto/_Xwa-lWTpivnlB1tgX-sJw/",
"caption": "Korean tacos ($5.75)",
"feedback_positive_count": 19,
}
48. Documentation -> JSON
● Included as submodule in client repos
● Build step to flatten documentation into JSON (e.g. v1-
-objects--photo+full.json)
● Code requests specific mocks
49. Why This Approach Works for Us
● API & client contract
● Fewer dependencies for developers & Jenkins
● Improved test speed & reliability on iOS & Android
60. View Tests
● Pros:
○ Easy way to catch regressions
○ Invaluable when refactoring or updating to new OS
versions
● Cons:
○ Slow: ~¾ seconds per test
○ Lots of false-positive failures
61. Integration Tests
● Testing that application behaves as expected
● Interaction between view controllers
● Primary signals of a problem:
○ Non-visual - analytics & network requests
○ Visual - button or label
● ~225 Integration tests
62. KIF
● ~150 KIF tests
● Uses accessibility labels to navigate
● Custom hooks for analytics, requests
● Continuous integration on Jenkins
● Separate iPad and iPhone tests
github.com/kif-framework/KIF
70. Acceptance Tests
● Test overall look and feel
● ~50 manual test cases
○ Moving some to KIF
● iOS7 & 8, iPad & iPhone
● Run by Engineers + PM during release process
71. Closing Thoughts
● API mocks make it easy for us to reliably grow our
testing suite
● Different types of tests for different problems
● Sandboxes to create consistent environments
● KIF <3
89. Problem: slow test suites
● Consequence of needing devices
● Long = impractical
90. Solution: test sharding
$ adb shell am instrument -w
-e numShards 4
-e shardIndex 1
● github.com/shazam/fork
● Resources are the limit!
91. Yelp Testing Process
● ~300 unit tests
● ~100 integration tests
● ~150 UI integration tests
● Manual testing against production
● Beta group
● 50% roll-out in Play Store
92. UI Integration test toolkit @ Yelp
● Espresso!
● Home-rolled MockHttpClient
o MockResponse
o MockRequestMatcher
● Spoon
93.
94.
95. public void test_ClickingBookmark_SendsAddRequestAndUpdatesUi() {
mBusiness.setBookmarked(false);
setActivityIntent(ActivityBusinessPage.intentForBusiness(getYelpContext(), mBusiness));
getActivity();
takeScreenshot("business detail unbookmarked");
// Check that the bookmark button has the text “Bookmark” and click on it
// This should trigger a bookmark add request.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.action_bookmark))))
.perform(click());
takeScreenshot("bookmarked");
// Make sure we hit bookmarks/add with the proper params.
assertThat(mAddBookmarkResponse.getRequestCount(), is(1));
// Make sure the "Bookmarked" button now shows.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.bookmarked))));
}
96. public void test_ClickingBookmark_SendsAddRequestAndUpdatesUi() {
mBusiness.setBookmarked(false);
setActivityIntent(ActivityBusinessPage.intentForBusiness(getYelpContext(), mBusiness));
getActivity();
takeScreenshot("business detail unbookmarked");
// Check that the bookmark button has the text “Bookmark” and click on it
// This should trigger a bookmark add request.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.action_bookmark))))
.perform(click());
takeScreenshot("bookmarked");
// Make sure we hit bookmarks/add with the proper params.
assertThat(mAddBookmarkResponse.getRequestCount(), is(1));
// Make sure the "Bookmarked" button now shows.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.bookmarked))));
}
97. public void test_ClickingBookmark_SendsAddRequestAndUpdatesUi() {
mBusiness.setBookmarked(false);
setActivityIntent(ActivityBusinessPage.intentForBusiness(getYelpContext(), mBusiness));
getActivity();
takeScreenshot("business detail unbookmarked");
// Check that the bookmark button has the text “Bookmark” and click on it
// This should trigger a bookmark add request.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.action_bookmark))))
.perform(click());
takeScreenshot("bookmarked");
// Make sure we hit bookmarks/add with the proper params.
assertThat(mAddBookmarkResponse.getRequestCount(), is(1));
// Make sure the "Bookmarked" button now shows.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.bookmarked))));
}
98. public void test_ClickingBookmark_SendsAddRequestAndUpdatesUi() {
mBusiness.setBookmarked(false);
setActivityIntent(ActivityBusinessPage.intentForBusiness(getYelpContext(), mBusiness));
getActivity();
takeScreenshot("business detail unbookmarked");
// Check that the bookmark button has the text “Bookmark” and click on it
// This should trigger a bookmark add request.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.action_bookmark))))
.perform(click());
takeScreenshot("bookmarked");
// Make sure we hit bookmarks/add with the proper params.
assertThat(mAddBookmarkResponse.getRequestCount(), is(1));
// Make sure the "Bookmarked" button now shows.
onView(withId(R.id.bookmark))
.check(matches(allOf(isDisplayed(), withText(R.string.bookmarked))));
}
99.
100.
101. public void test_WriteTip_SendsProperRequest() {
setMockSession();
MockResponse tipSaveResponse = mMockHttpClient.addDefaultMock(
ApiPath.QUICKTIPS_SAVE, getTipSaveParams());
openTipPage();
onView(withId(R.id.edit_text)).perform(typeText(TIP_TEXT));
takeScreenshot("tip typed");
onView(withId(R.id.done_button)).perform(click());
takeScreenshot("business page");
// We should show a "Thanks for the tip!" dialog on the business page.
onView(withText(R.string.thanks_for_the_tip)).check(matches(isDisplayed()));
assertThat(tipSaveResponse.getRequestCount(), is(1));
}
102. public void test_WriteTip_SendsProperRequest() {
setMockSession();
MockResponse tipSaveResponse = mMockHttpClient.addDefaultMock(
ApiPath.QUICKTIPS_SAVE, getTipSaveParams());
openTipPage();
onView(withId(R.id.edit_text)).perform(typeText(TIP_TEXT));
takeScreenshot("tip typed");
onView(withId(R.id.done_button)).perform(click());
takeScreenshot("business page");
// We should show a "Thanks for the tip!" dialog on the business page.
onView(withText(R.string.thanks_for_the_tip)).check(matches(isDisplayed()));
assertThat(tipSaveResponse.getRequestCount(), is(1));
}
103. public void test_WriteTip_SendsProperRequest() {
setMockSession();
MockResponse tipSaveResponse = mMockHttpClient.addDefaultMock(
ApiPath.QUICKTIPS_SAVE, getTipSaveParams());
openTipPage();
onView(withId(R.id.edit_text)).perform(typeText(TIP_TEXT));
takeScreenshot("tip typed");
onView(withId(R.id.done_button)).perform(click());
takeScreenshot("business page");
// We should show a "Thanks for the tip!" dialog on the business page.
onView(withText(R.string.thanks_for_the_tip)).check(matches(isDisplayed()));
assertThat(tipSaveResponse.getRequestCount(), is(1));
}
104. public void test_WriteTip_SendsProperRequest() {
setMockSession();
MockResponse tipSaveResponse = mMockHttpClient.addDefaultMock(
ApiPath.QUICKTIPS_SAVE, getTipSaveParams());
openTipPage();
onView(withId(R.id.edit_text)).perform(typeText(TIP_TEXT));
takeScreenshot("tip typed");
onView(withId(R.id.done_button)).perform(click());
takeScreenshot("business page");
// We should show a "Thanks for the tip!" dialog on the business page.
onView(withText(R.string.thanks_for_the_tip)).check(matches(isDisplayed()));
assertThat(tipSaveResponse.getRequestCount(), is(1));
}
105.
106.
107. ● Library choices matter
● Address the issues at the source!
● Tests don’t have to be a pain
Lessons learned