$30 off During Our Annual Pro Sale. View Details »

Diving into the inner workings of OkReplay

Diving into the inner workings of OkReplay

In this talk we covered the internals of the OkReplay library for recording and replaying Espresso tests.

felipecsl

May 18, 2018
Tweet

More Decks by felipecsl

Other Decks in Programming

Transcript

  1. Diving into the inner
    workings of OkReplay
    FELIPE LIMA / @FELIPECSL / MAY 17, 2018 / DROIDCHELLA

    View Slide

  2. WHAT’S OKREPLAY?

    View Slide

  3. Story time!

    View Slide

  4. WAIT, BUT WHY?

    View Slide

  5. Timeline
    VCR
    2010

    View Slide

  6. Timeline
    VCR BETAMAX
    2010 2011

    View Slide

  7. Timeline
    VCR BETAMAX OKREPLAY
    2010 2011 2017

    View Slide

  8. Test Pyramid

    View Slide

  9. Test Pyramid

    View Slide

  10. ARCHITECTURE

    View Slide

  11. • okreplay-core
    • okreplay-junit
    • okreplay-espresso
    • okreplay-gradle-plugin
    • okreplay-sample
    • okreplay-noop
    • okreplay-tests
    Modular architecture

    View Slide

  12. • Yaml file where a list of http “interactions” is recorded
    • Each test method corresponds to a tape file
    • Interactions are recorded in request-response pairs
    • Includes method, URL, status, headers and response body
    • Modes: read-only, read-write, write-only, etc.
    • Read from instrumentation APK “assets” directory
    • Writes to device’s external storage directory
    Tape
    WHERE INTERACTIONS HAPPEN

    View Slide

  13. Tape file !tape
    name: test league
    interactions:
    - recorded: 2017-07-31T03:46:40.477Z
    request:
    method: GET
    uri: http://elifut.com/nations.json
    response:
    status: 200
    headers:
    Cache-Control: max-age=0, private, must-revalidate
    Connection: keep-alive
    Content-Type: application/json; charset=utf-8
    Date: Mon, 31 Jul 2017 03:47:07 GMT
    Server: nginx/1.10.3 + Phusion Passenger 5.1.5
    Status: 200 OK
    Transfer-Encoding: chunked
    X-Content-Type-Options: nosniff
    X-Frame-Options: SAMEORIGIN
    X-Powered-By: Phusion Passenger 5.1.5
    X-Runtime: '0.012716'
    X-XSS-Protection: 1; mode=block
    body: ''

    View Slide

  14. Tape class hierarchy
    Tape
    Concrete class
    Abstract class
    Interface

    View Slide

  15. Tape class hierarchy
    Tape
    MemoryTape
    Concrete class
    Abstract class
    Interface

    View Slide

  16. Tape class hierarchy
    Tape
    MemoryTape
    YamlTape
    Concrete class
    Abstract class
    Interface

    View Slide

  17. Core
    OKHTTP INTERCEPTOR
    @Override
    public Response intercept(Chain chain) {
    Request request = chain.request();
    if (isRunning) {
    Request recordedRequest = OkHttpRequestAdapter.adapt(request);
    if (tape.isReadable() && tape.seek(recordedRequest)) {
    return replayTape(request, tape, recordedRequest);
    } else {
    return recordResponse(chain, request, tape, recordedRequest);
    }
    } else {
    return chain.proceed(request);
    }
    }

    View Slide

  18. Core
    OKHTTP INTERCEPTOR
    @Override
    public Response intercept(Chain chain) {
    Request request = chain.request();
    if (isRunning) {
    Request recordedRequest = OkHttpRequestAdapter.adapt(request);
    if (tape.isReadable() && tape.seek(recordedRequest)) {
    return replayTape(request, tape, recordedRequest);
    } else {
    return recordResponse(chain, request, tape, recordedRequest);
    }
    } else {
    return chain.proceed(request);
    }
    }

    View Slide

  19. Core
    OKHTTP INTERCEPTOR
    @Override
    public Response intercept(Chain chain) {
    Request request = chain.request();
    if (isRunning) {
    Request recordedRequest = OkHttpRequestAdapter.adapt(request);
    if (tape.isReadable() && tape.seek(recordedRequest)) {
    return replayTape(request, tape, recordedRequest);
    } else {
    return recordResponse(chain, request, tape, recordedRequest);
    }
    } else {
    return chain.proceed(request);
    }
    }

    View Slide

  20. Core
    OKHTTP INTERCEPTOR
    @Override
    public Response intercept(Chain chain) {
    Request request = chain.request();
    if (isRunning) {
    Request recordedRequest = OkHttpRequestAdapter.adapt(request);
    if (tape.isReadable() && tape.seek(recordedRequest)) {
    return replayTape(request, tape, recordedRequest);
    } else {
    return recordResponse(chain, request, tape, recordedRequest);
    }
    } else {
    return chain.proceed(request);
    }
    }

    View Slide

  21. Core
    OKHTTP INTERCEPTOR
    @Override
    public Response intercept(Chain chain) {
    Request request = chain.request();
    if (isRunning) {
    Request recordedRequest = OkHttpRequestAdapter.adapt(request);
    if (tape.isReadable() && tape.seek(recordedRequest)) {
    return replayTape(request, tape, recordedRequest);
    } else {
    return recordResponse(chain, request, tape, recordedRequest);
    }
    } else {
    return chain.proceed(request);
    }
    }

    View Slide

  22. Core
    OKHTTP INTERCEPTOR
    @Override
    public Response intercept(Chain chain) {
    Request request = chain.request();
    if (isRunning) {
    Request recordedRequest = OkHttpRequestAdapter.adapt(request);
    if (tape.isReadable() && tape.seek(recordedRequest)) {
    return replayTape(request, tape, recordedRequest);
    } else {
    return recordResponse(chain, request, tape, recordedRequest);
    }
    } else {
    return chain.proceed(request);
    }
    }

    View Slide

  23. Replaying Responses
    private okhttp3.Response replayResponse(
    okhttp3.Request request,
    Tape tape,
    Request recordedRequest
    ) {
    Response recordedResponse = tape.play(recordedRequest);
    okhttp3.Response okhttpResponse =
    OkHttpResponseAdapter.adapt(request, recordedResponse);
    okhttpResponse = setOkReplayHeader(okhttpResponse, "PLAY");
    okhttpResponse = setViaHeader(okhttpResponse);
    return okhttpResponse;
    }

    View Slide

  24. Replaying Responses
    abstract class MemoryTape implements Tape {
    @Override public Response play(final Request request) {
    if (mode.isSequential()) {
    Integer nextIndex = orderedIndex.getAndIncrement();
    RecordedInteraction nextInteraction = interactions.get(nextIndex).toImmutable();
    return nextInteraction.response();
    } else {
    int position = findMatch(request);
    if (position < 0) {
    throw new IllegalStateException("no matching recording found");
    } else {
    return interactions.get(position).toImmutable().response();
    }
    }
    }
    }

    View Slide

  25. Replaying Responses
    abstract class MemoryTape implements Tape {
    @Override public Response play(final Request request) {
    if (mode.isSequential()) {
    Integer nextIndex = orderedIndex.getAndIncrement();
    RecordedInteraction nextInteraction = interactions.get(nextIndex).toImmutable();
    return nextInteraction.response();
    } else {
    int position = findMatch(request);
    if (position < 0) {
    throw new IllegalStateException("no matching recording found");
    } else {
    return interactions.get(position).toImmutable().response();
    }
    }
    }
    }

    View Slide

  26. Replaying Responses
    abstract class MemoryTape implements Tape {
    @Override public Response play(final Request request) {
    if (mode.isSequential()) {
    Integer nextIndex = orderedIndex.getAndIncrement();
    RecordedInteraction nextInteraction = interactions.get(nextIndex).toImmutable();
    return nextInteraction.response();
    } else {
    int position = findMatch(request);
    if (position < 0) {
    throw new IllegalStateException("no matching recording found");
    } else {
    return interactions.get(position).toImmutable().response();
    }
    }
    }

    }

    View Slide

  27. OkReplay Annotation
    @Retention(AnnotationRetention.RUNTIME)
    @Target(AnnotationTarget.FUNCTION,
    AnnotationTarget.CLASS,
    AnnotationTarget.FILE)
    annotation class OkReplay(
    val tape: String = "",
    val mode: TapeMode = TapeMode.UNDEFINED,
    val match: Array = []
    )

    View Slide

  28. Tape Modes
    public enum TapeMode {
    UNDEFINED,
    READ_WRITE,
    READ_ONLY,
    READ_ONLY_QUIET,
    READ_SEQUENTIAL,
    WRITE_ONLY,
    WRITE_SEQUENTIAL;
    }

    View Slide

  29. x
    Match Rules
    public enum MatchRules implements MatchRule {
    method {
    @Override public boolean isMatch(Request a, Request b) {
    return a.method().equalsIgnoreCase(b.method());
    }
    }, uri {
    @Override public boolean isMatch(Request a, Request b) {
    return a.url().equals(b.url());
    }
    }, host {
    @Override public boolean isMatch(Request a, Request b) {
    return a.url().url().getHost().equals(b.url().url().getHost());
    }
    }, path {
    @Override public boolean isMatch(Request a, Request b) {
    return a.url().url().getPath().equals(b.url().url().getPath());
    }
    }, port {
    @Override public boolean isMatch(Request a, Request b) {
    return a.url().url().getPort() == b.url().url().getPort();
    }
    }
    }

    View Slide

  30. Test Rule class RecorderRule(
    configuration: OkReplayConfig
    ) : Recorder(configuration), TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
    val annotation = description.getAnnotation(OkReplay::class.java)
    if (annotation != null) {
    return object : Statement() {
    override fun evaluate() {
    try {
    val tapeName = annotation.tape
    val tapeMode = annotation.mode
    val matchRules = annotation.match
    val matchRule = ComposedMatchRule.of(*matchRules)
    start(tapeName, tapeMode.toOptional(), matchRule)
    statement.evaluate()
    } catch (e: Exception) {
    throw e
    } finally {
    stop()
    }
    }
    }
    } else {
    return statement
    }
    }
    }

    View Slide

  31. Test Rule class RecorderRule(
    configuration: OkReplayConfig
    ) : Recorder(configuration), TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
    val annotation = description.getAnnotation(OkReplay::class.java)
    if (annotation != null) {
    return object : Statement() {
    override fun evaluate() {
    try {
    val tapeName = annotation.tape
    val tapeMode = annotation.mode
    val matchRules = annotation.match
    val matchRule = ComposedMatchRule.of(*matchRules)
    start(tapeName, tapeMode.toOptional(), matchRule)
    statement.evaluate()
    } catch (e: Exception) {
    throw e
    } finally {
    stop()
    }
    }
    }
    } else {
    return statement
    }
    }
    }

    View Slide

  32. Test Rule class RecorderRule(
    configuration: OkReplayConfig
    ) : Recorder(configuration), TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
    val annotation = description.getAnnotation(OkReplay::class.java)
    if (annotation != null) {
    return object : Statement() {
    override fun evaluate() {
    try {
    val tapeName = annotation.tape
    val tapeMode = annotation.mode
    val matchRules = annotation.match
    val matchRule = ComposedMatchRule.of(*matchRules)
    start(tapeName, tapeMode.toOptional(), matchRule)
    statement.evaluate()
    } catch (e: Exception) {
    throw e
    } finally {
    stop()
    }
    }
    }
    } else {
    return statement
    }
    }
    }

    View Slide

  33. Test Rule class RecorderRule(
    configuration: OkReplayConfig
    ) : Recorder(configuration), TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
    val annotation = description.getAnnotation(OkReplay::class.java)
    if (annotation != null) {
    return object : Statement() {
    override fun evaluate() {
    try {
    val tapeName = annotation.tape
    val tapeMode = annotation.mode
    val matchRules = annotation.match
    val matchRule = ComposedMatchRule.of(*matchRules)
    start(tapeName, tapeMode.toOptional(), matchRule)
    statement.evaluate()
    } catch (e: Exception) {
    throw e
    } finally {
    stop()
    }
    }
    }
    } else {
    return statement
    }
    }
    }

    View Slide

  34. Test Rule class RecorderRule(
    configuration: OkReplayConfig
    ) : Recorder(configuration), TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
    val annotation = description.getAnnotation(OkReplay::class.java)
    if (annotation != null) {
    return object : Statement() {
    override fun evaluate() {
    try {
    val tapeName = annotation.tape
    val tapeMode = annotation.mode
    val matchRules = annotation.match
    val matchRule = ComposedMatchRule.of(*matchRules)
    start(tapeName, tapeMode.toOptional(), matchRule)
    statement.evaluate()
    } catch (e: Exception) {
    throw e
    } finally {
    stop()
    }
    }
    }
    } else {
    return statement
    }
    }
    }

    View Slide

  35. Test Rule class RecorderRule(
    configuration: OkReplayConfig
    ) : Recorder(configuration), TestRule {
    override fun apply(statement: Statement, description: Description): Statement {
    val annotation = description.getAnnotation(OkReplay::class.java)
    if (annotation != null) {
    return object : Statement() {
    override fun evaluate() {
    try {
    val tapeName = annotation.tape
    val tapeMode = annotation.mode
    val matchRules = annotation.match
    val matchRule = ComposedMatchRule.of(*matchRules)
    start(tapeName, tapeMode.toOptional(), matchRule)
    statement.evaluate()
    } catch (e: Exception) {
    throw e
    } finally {
    stop()
    }
    }
    }
    } else {
    return statement
    }
    }
    }

    View Slide

  36. Recorder Rule
    STARTING THE TAPE
    public class Recorder {
    public void start(
    String tapeName,
    Optional mode,
    Optional matchRule
    ) {
    tape = getTapeLoader().loadTape(tapeName);
    tape.setMode(mode.or(configuration.getDefaultMode()));
    tape.setMatchRule(matchRule.or(configuration.getDefaultMatchRule()));
    configuration.interceptor().start(configuration, tape);
    }
    }

    View Slide

  37. Class hierarchy Message
    Request Response
    RecordedRequest RecordedResponse
    AbstractMessage
    RecordedMessage
    Concrete class
    Abstract class
    Interface
    MAIN ENTITIES

    View Slide

  38. Usage
    @RunWith(AndroidJUnit4.class)
    public class ExampleInstrumentationTest {
    private final ActivityTestRule activityTestRule =
    new ActivityTestRule<>(MainActivity.class);
    private final OkReplayConfig configuration = new OkReplayConfig.Builder()
    .tapeRoot(new AndroidTapeRoot(getContext(), getClass()))
    .defaultMode(TapeMode.READ_WRITE)
    .interceptor(new OkReplayInterceptor())
    .defaultMatchRules(MatchRules.host, MatchRules.path, MatchRules.method)
    .build();
    @Rule public final TestRule testRule =
    new OkReplayRuleChain(configuration, activityTestRule).get();
    @Test
    @OkReplay
    public void bar() {
    onView(withId(R.id.foo)).perform(click());
    onView(withId(R.id.bar)).check(matches(withText(containsString("bar"))));
    }
    }

    View Slide

  39. Usage
    @RunWith(AndroidJUnit4.class)
    public class ExampleInstrumentationTest {
    private final ActivityTestRule activityTestRule =
    new ActivityTestRule<>(MainActivity.class);
    private final OkReplayConfig configuration = new OkReplayConfig.Builder()
    .tapeRoot(new AndroidTapeRoot(getContext(), getClass()))
    .defaultMode(TapeMode.READ_WRITE)
    .interceptor(new OkReplayInterceptor())
    .defaultMatchRules(MatchRules.host, MatchRules.path, MatchRules.method)
    .build();
    @Rule public final TestRule testRule =
    new OkReplayRuleChain(configuration, activityTestRule).get();
    @Test
    @OkReplay
    public void bar() {
    onView(withId(R.id.foo)).perform(click());
    onView(withId(R.id.bar)).check(matches(withText(containsString("bar"))));
    }
    }

    View Slide

  40. Usage
    @RunWith(AndroidJUnit4.class)
    public class ExampleInstrumentationTest {
    private final ActivityTestRule activityTestRule =
    new ActivityTestRule<>(MainActivity.class);
    private final OkReplayConfig configuration = new OkReplayConfig.Builder()
    .tapeRoot(new AndroidTapeRoot(getContext(), getClass()))
    .defaultMode(TapeMode.READ_WRITE)
    .interceptor(new OkReplayInterceptor())
    .defaultMatchRules(MatchRules.host, MatchRules.path, MatchRules.method)
    .build();
    @Rule public final TestRule testRule =
    new OkReplayRuleChain(configuration, activityTestRule).get();
    @Test
    @OkReplay
    public void bar() {
    onView(withId(R.id.foo)).perform(click());
    onView(withId(R.id.bar)).check(matches(withText(containsString("bar"))));
    }
    }

    View Slide

  41. Usage
    @RunWith(AndroidJUnit4.class)
    public class ExampleInstrumentationTest {
    private final ActivityTestRule activityTestRule =
    new ActivityTestRule<>(MainActivity.class);
    private final OkReplayConfig configuration = new OkReplayConfig.Builder()
    .tapeRoot(new AndroidTapeRoot(getContext(), getClass()))
    .defaultMode(TapeMode.READ_WRITE)
    .interceptor(new OkReplayInterceptor())
    .defaultMatchRules(MatchRules.host, MatchRules.path, MatchRules.method)
    .build();
    @Rule public final TestRule testRule =
    new OkReplayRuleChain(configuration, activityTestRule).get();
    @Test
    @OkReplay
    public void bar() {
    onView(withId(R.id.foo)).perform(click());
    onView(withId(R.id.bar)).check(matches(withText(containsString("bar"))));
    }
    }

    View Slide

  42. PITFALLS

    View Slide

  43. • Granting WRITE_EXTERNAL_STORAGE permission
    • Handling secrets
    • Re-recording tapes
    • If your test fails, you’ll need to re-record the tapes
    • Switching between replaying/recording modes
    Pitfalls

    View Slide

  44. • Writing stable Espresso tests is hard
    • A lot of cool concepts can be brought in from other platforms
    • Naming is also hard
    • Writing stable and reproducible Android UI tests is possible
    Conclusion
    FINAL REMARKS

    View Slide

  45. QUESTIONS?

    View Slide

  46. THANKS!

    View Slide