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.

7fd4ba468da56bb5330a6352c1b54f52?s=128

felipecsl

May 18, 2018
Tweet

Transcript

  1. Diving into the inner workings of OkReplay FELIPE LIMA /

    @FELIPECSL / MAY 17, 2018 / DROIDCHELLA
  2. WHAT’S OKREPLAY?

  3. Story time!

  4. WAIT, BUT WHY?

  5. Timeline VCR 2010

  6. Timeline VCR BETAMAX 2010 2011

  7. Timeline VCR BETAMAX OKREPLAY 2010 2011 2017

  8. Test Pyramid

  9. Test Pyramid

  10. ARCHITECTURE

  11. • okreplay-core • okreplay-junit • okreplay-espresso • okreplay-gradle-plugin • okreplay-sample

    • okreplay-noop • okreplay-tests Modular architecture
  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
  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: '<redacted>'
  14. Tape class hierarchy Tape Concrete class Abstract class Interface

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

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

    Interface
  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); } }
  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); } }
  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); } }
  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); } }
  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); } }
  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); } }
  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; }
  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(); } } } }
  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(); } } } }
  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(); } } }
 }
  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<MatchRules> = [] )
  28. Tape Modes public enum TapeMode { UNDEFINED, READ_WRITE, READ_ONLY, READ_ONLY_QUIET,

    READ_SEQUENTIAL, WRITE_ONLY, WRITE_SEQUENTIAL; }
  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(); } } }
  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 } } }
  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 } } }
  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 } } }
  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 } } }
  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 } } }
  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 } } }
  36. Recorder Rule STARTING THE TAPE public class Recorder { public

    void start( String tapeName, Optional<TapeMode> mode, Optional<MatchRule> matchRule ) { tape = getTapeLoader().loadTape(tapeName); tape.setMode(mode.or(configuration.getDefaultMode())); tape.setMatchRule(matchRule.or(configuration.getDefaultMatchRule())); configuration.interceptor().start(configuration, tape); } }
  37. Class hierarchy Message Request Response RecordedRequest RecordedResponse AbstractMessage RecordedMessage Concrete

    class Abstract class Interface MAIN ENTITIES
  38. Usage @RunWith(AndroidJUnit4.class) public class ExampleInstrumentationTest { private final ActivityTestRule<MainActivity> 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")))); } }
  39. Usage @RunWith(AndroidJUnit4.class) public class ExampleInstrumentationTest { private final ActivityTestRule<MainActivity> 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")))); } }
  40. Usage @RunWith(AndroidJUnit4.class) public class ExampleInstrumentationTest { private final ActivityTestRule<MainActivity> 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")))); } }
  41. Usage @RunWith(AndroidJUnit4.class) public class ExampleInstrumentationTest { private final ActivityTestRule<MainActivity> 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")))); } }
  42. PITFALLS

  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
  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
  45. QUESTIONS?

  46. THANKS!