Skip to content

Instantly share code, notes, and snippets.

@sockeqwe
Last active August 26, 2019 15:44
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sockeqwe/46134db906ac80faa698e25c3dde6ab0 to your computer and use it in GitHub Desktop.
Save sockeqwe/46134db906ac80faa698e25c3dde6ab0 to your computer and use it in GitHub Desktop.

Demonstrates how to write integration tests that can run on both, JVM (mocked View) and real UI (emulator or real device). Scenario is a newspaper app that shows a list of news articles (NewsListActivity). You can click on one to open the details screen (NewsDetailsActivity). In details screen you can mark a news article as favorite one. By doing so, also the news list will be updated and display another icon(favorite icon) for the news article in the news list (in background news list activity is still open). Also, since everything is push based (MVI pushes always new states, right) we don't have to use idling resources because you can use blockingAwait() in your tests and then continue triggering the next action after a state change

data class News(
val title : String
val favorite : Boolean
)
data class NewsDetailsState {
object Loading : NewsDetailsState()
data class Error(val error : Throwable) : NewsDetailsState()
data class Content(val news : News) : NewsListState()
}
interface NewsDetailsView {
fun load() : Observable<Unit> // Action to load the specified news article from repo
fun toggleMarkedAsFavorite() : Observable<Unit> // mark / unmark a news article as a favorite one
fun render(state : NewsDetailState)
}
open class NewsDetailsViewBinding(protected val root : View) {
private val content : ViewGroup by lazy { root.findViewById(R.id.contet)}
private val loadingView : View by lazy { root.findViewById(R.id.loading)}
private val errorView : ErrorView by lazy { root.findViewById(R.id.error)}
private val title: TextView by lazy { root.findViewById(R.id.title) }
private val toggleFavoriteButton : View by lazy { root.findViewById(R.id.toogleFavoriteButton) }
fun toggleMarkedAsFavorite() : Observable<Unit> = toggleFavoriteButton.clicks()
open fun render(s : NewsDetailState){
when (s) {
// Just sets UI widgets accordingly to the current state
// ...
}
}
}
class NewsDetailsActivity : NewsDetailsView, Activity(){
@Inject lateinit var binding : NewsDetailsViewBinding
override fun onCreate(b : Bundle?) {
super(b)
setContentView(R.layout.news_details)
someMagicDependencyInjection() // Injects NewsDetailsViewBinding
}
fun load() : Observable<Unit> = Observable.just(Unit)
fun toggleMarkedAsFavorite() : Observable<Unit> = binding.toggleMarkedAsFavorite()
fun render(state : NewsDetailState) {
binding.render(state)
}
}
class NewsDetailsPresenter(
private val newsId : Int,
private val repo : NewsListRepo
) : MviPresenter<NewsDetailsView>{
override fun bindIntents(){
// Exercise for the reader :)
val state : Observable<NewsDetailState> = ...
state.subscribe(view::render)
}
}
// I'm not building the whole Robot DSL (I'm too lazy), I think you can imagine how such a dsl could look like in a real application
//
// Actually, this is not exactly the same as the "Robot" pattern.
// We split a tradtional Robot in 3 parts / classes:
// 1. Inputs: To trigger inputs i.e. by using espresso to click on a news list item
// 2. Output: To record the state changes over time
// 3. Verifications: Verify that an input has caused a certain output
// Only used for in memory JVM tests
class MockedNewsDetailsView : NewsDetailsView {
val toggleFavorite = PublishRelay.create<Unit>()
val renderedStates : Observable<List<NewsDetailsState>>() = _states // All rendered states over time
... // The rest is pretty much the same as for NewsDetailsList, so render intercepts and records states and so on
}
//
// 1. Inputs
//
interface NewsDetailsInputs {
fun toggleFavorite()
fun clickBackButton()
}
// Inputs for a real UI (using Espresso)
class UiNewsListInputs : NewsDetailsInputs {
override fun toggleFavorite(){
onView(withId(R.id.toogleFavoriteButton))
.perform(click()))
}
override fun clickBackButton(){
// Somehow use espresso to click the back button
// ...
}
}
// This mocks the View
class JvmNewsDetailsInputs(private val view : MockedNewsDetailsView) : NewsListInpus {
override fun clickBackButton(){
// nothing to do on a mocked view
}
override fun toggleFavorite(){
view.toggleFavorite.accept(Unit)
}
}
//
// 2. Output
//
interface NewsDetailsOutput {
fun renderedStates() : Observable<List<NewsDetailsState>>
}
// Used to record state changes from real UI
// We also do screenshot testing (library von facebook)
class RecordingNewsDetailViewBinding : NewsDetailViewBinding {
val renderedStates : Observable<List<NewsDetailsState>>() = _states // All rendered states over time
private val _states = ReplayRelay.create<List<NewsDetailsState>()
private val lastStates : List<NewsDetailsState> = emptyList()
override fun render(state : NewsDetailsState){
super.render(state) // causes UI widgets to change
// Screenshot test to verify that UI is looking as expected
Screenshot.snap(root).record() // root is from superclass
//
// Recording all states over time
//
val newList = ArrayList(lastStates)
newList += state
_states.accept(newList)
lastStates = newList
}
}
class UiNewsDetailOutput(private val binding : RecordingNewsDetailViewBinding) : NewsDetailsOutput{
override fun renderedStates() : Observable<List<NewsDetailsState>> = binding.renderedStates
}
class JvmNEwsListOutput(private val view : MockedNewsDetailsView) : NewsDetailsOutput{
override fun renderedStates() : Observable<List<NewsDetailsState>> = view.renderedState
}
//
// 3. verifications
//
class NewsListVerifications(val output : NewsDetailsOutput){
private fun blockingWaitForStates(vararg expectedStates : NewsDetailsState){
// We always take the full history of state changes / transitions into account
val actualStates : List<NewsDetailsState> = output.renderedStates()
.take(expectedStates.size)
.timeout(10, TimeUnit.Seconds)
.blockingGet() // That's the trick --> We don't need idling resources at all!!!
assertEquals(states.toList(), actualStates)
}
fun loadingShown(){
blockingWaitForStates(NewsDetailsState.Loading)
}
fun errorShown(){
blockingWaitForStates(NewsDetailsState.Loading, NewsDetailsState.Error( mockedException ) )
}
fun contentShown(){
val contentState = NewsDetailsState.Content( mockedNewsArticle )
blockingWaitForStates(NewsDetailsState.Loading, contentState)
}
fun newsArticleDisplayedAsFavorite(){
val contentState = NewsListState.Content( mockedNewsArticle )
val updatedContentState = NewsListState.Content( mockedNewsArticle.copy(favorite = true) )
blockingWaitForStates(NewsDetailsState.Loading, contentState, updatedContentState)
}
}
selaed class NewsListState {
object Loading : NewsListState()
data class Error(val error : Throwable) : NewsListState()
/**
* Displays a list of News articles. An News article can be marked as favorite (UI displays a star icon)
*/
data class Content(val items : List<News>) : NewsListState()
}
interface NewsListView {
fun loadIntent() : Observable<Unit>
fun openNewsDetailsIntent() : Observable<Int> // NewsId
fun render (state : NewsListState)
}
open class NewsListViewBinding(protected val root : View) : NewsListView {
private val recyclerView : RecyclerView by lazy { root.findViewById(R.id.recyclerView)}
private val loadingView : View by lazy { root.findViewById(R.id.loading)}
private val errorView : ErrorView by lazy { root.findViewById(R.id.error)}
override fun loadIntent() = Observable.just(Unit) // Triggered as soon as Presenter subscribes to it (which happens in Activity.onStart()
override fun openNewsDetailsIntent() = ... // Somehow trigger this observable whenever we click on a item in RecyclerView
open override fun render(state : NewsListState){
when (state){
is Loading -> ...
is Error -> ...
is Content -> ...
}
}
}
class NewsListActivity : NewsListView, Activity() {
@Inject lateinit var binding : NewsListViewBinding
override fun onCreate(b: Bundle?){
super(b)
setContentView(R.layout.news_list)
doSomeDependencyInjectionMagic() // Injects NewsListViewBinding
}
override fun loadIntent() = binding.loadIntent()
override fun openNewsDetailsIntent() = binding.openNewsDetailsIntent()
override fun render(s : NewsListState) {
binding.render(s)
}
}
class NewsListPresenter(
private val repo : NewsListRepo,
val openNewsId: (Int) -> Unit // Callback for coordinator to navigate to the news details item
) : MviPresenter<NewsListView> {
override fun bindIntents() {
// bad implementation but who cares :)
val state = view.loadIntent().switchMap {
repo.getNewsList().map { NewsListState.Content(it) }
}
.startWith( NewsListState.Loading)
.onErrorReturn { NewsListState.Error(it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(view::render)
}
}
// I'm not building the whole Robot DSL (I'm too lazy), I think you can imagine how such a dsl could look like in a real application
//
// Actually, this is not exactly the same as the "Robot" pattern.
// We split a tradtional Robot in 3 parts / classes:
// 1. Inputs: To trigger inputs i.e. by using espresso to click on a news list item
// 2. Output: To record the state changes over time
// 3. Verifications: Verify that an input has caused a certain output
// Only used for in memory JVM tests
class MockedNewsListView : NewsListView {
val openNewsIntent = PublishRelay.create<Int>()
val renderedStates : Observable<List<NewsListState>>() = _states // All rendered states over time
private val _states = ReplayRelay.create<List<NewsListState>()
private val lastStates : List<NewsListState> = emptyList()
override fun loadIntent() = Observable.just(Unit)
override fun openNewsDetailsIntent() = openNewsIntent
override fun render(state : State){
// Record all states over time ... Pretty ugly code, just to demonstrate the idea
val newList = ArrayList(lastStates)
newList += state
_states.accept(newList)
lastStates = newList
}
}
//
// 1. Inputs
//
interface NewsListInputs {
fun scrollDownList()
fun clickOnNewsArticleWithId(newsId : Int)
}
// Inputs for a real UI (using Espresso)
class UiNewsListInputs : NewsListInputs {
override fun scrollDownList(){
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollDown(7))
}
override fun clickOnNewsArticleWithId(newsId : Int){
// Somehow use espresso to click on a certain item in
// ...
}
}
// This mocks the View
class JvmNewsListInputs(private val view : MockedNewsListView) : NewsListInpus {
override fun scrollDownList(){
// nothing to do on a mocked view
}
override fun clickOnNewsArticleWithId(newsId : Int){
view.openNewsIntent.accept(newsId)
}
}
//
// 2. Output
//
interface NewsListOutput {
fun renderedStates() : Observable<List<NewsListState>>
}
// Used to record state changes from real UI
// We also do screenshot testing (library von facebook)
class RecordingNewsListViewBinding : NewsListViewBinding {
val renderedStates : Observable<List<NewsListState>>() = _states // All rendered states over time
private val _states = ReplayRelay.create<List<NewsListState>()
private val lastStates : List<NewsListState> = emptyList()
override fun render(state : NewsListState){
super.render(state) // causes UI widgets to change
// Screenshot test to verify that UI is looking as expected
Screenshot.snap(root).record() // root is from superclass
//
// Recording all states over time
//
val newList = ArrayList(lastStates)
newList += state
_states.accept(newList)
lastStates = newList
}
}
class UiNewsListOutput(private val binding : RecordingNewsListViewBinding) : NewsListOutput{
override fun renderedStates() : Observable<List<NewsListState>> = binding.renderedStates
}
class JvmNEwsListOutput(private val view : MockNewsListView) : NewsListOutput{
override fun renderedStates() : Observable<List<NewsListState>> = view.renderedState
}
//
// 3. verifications
//
class NewsListVerifications(val output : NewsListOutput){
private fun blockingWaitForStates(vararg expectedStates : NewsListState){
// We always take the full history of state changes / transitions into account
val actualStates : List<NewsListState> = output.renderedStates()
.take(expectedStates.size)
.timeout(10, TimeUnit.Seconds)
.blockingGet() // That's the trick --> We don't need idling resources at all!!!
assertEquals(states.toList(), actualStates)
}
fun loadingShown(){
blockingWaitForStates(NewsListState.Loading)
}
fun errorShown(){
blockingWaitForStates(NewsListState.Loading, NewsListState.Error( mockedException ) )
}
fun contentShown(){
val contentState = NewsListState.Content( someMockeNewsList )
blockingWaitForStates(NewsListState.Loading, contentState)
}
fun contentUpdatedBecauseNewsListItemMarkedAsFavorite(){
val contentState = NewsListState.Content( someMockeNewsList )
val itemSupposedToBeChanged = someMockedList.last()
val changedItem = itemSupposedToBeChanged.copy(favorite = true)
val updatedList = someMockedList - itemSupposedToBeChanged + changedItem
val updatedContentState = NewsListState.Content( updatedList )
blockingWaitForStates(NewsListState.Loading, contentState, updatedContentState)
}
}
fun loadNewsListAndOpenDetails(input : NewsListInputs, verifications : NewsListVerifications) = Completeable {
verifications.loadingShown()
verifications.contentShown()
input.scrollDownList()
input.clickOnNewsArticleWithId(7)
}
fun showDetailsAndToggleFavorite(input : NewsDetailsInput, verifications: NewsDetailsVerifications) = Completable {
verifications.loadingShown()
verifications.contentShown()
input.toggleFavorite()
verifications.newsArticleDisplayedAsFavorite()
}
fun newsListUpdatedBecauseItemMarkedAsFavorite(verifications : NewsListVerifications) = Completeable {
verifications.contentUpdatedBecauseNewsListItemMarkedAsFavorite()
}
// Functional test that tests a certain user flow:
// 1. NewsList shown
// 2. User clicks on News article
// 3. News article is displayed
// 4. Mark news article as favorite
// 5. Marking news as favorite will also update the NewsList which is still open in background
fun showNewsListThenMarkFavoriteWillAlsoUpdateNewsList(
newsListInputs : NewsListInputs,
newsListVerifications : NewsListVerifications,
newsDetailsInputs : NewsDetailsInputs,
newsDetailsVerification : NewsDetailsVerification
) : Completable =
loadNewsListAndOpenDetails(newsListInputs, newsListVerifications)
.andThen(
Completable.merge ( // run open details and waiting for newslist update in parrallel
showDetailsAndToggleFavorite(newsDetailsInputs, newsDetailsVerification)
.subscribeOn(Schedulers.newThread()),
newsListUpdatedBecauseItemMarkedAsFavorite(newsListInputs, newsListVerifications)
.subscribeOn(Schedulers.newThread())
)
)
//
// Conclusion:
//
// Then run functional / integration test showNewsListThenMarkFavoriteWillAlsoUpdateNewsList(...)
// either as JVM Test or as UI Test on a real device / emulator by using the corresponsind Inputs, Output and Verification.
// Running on device / emulator requires that you Inject the RecordingViewBinding version into you app.
// Also, note that we run a full integration test from real UI to mocked data layer,
// but everything in between is real production code (no mocked presenter, no mocked UI, no mocked business logic, no mocked RxSchedulers)
//
// Use Junit, Spek or whatever you prefer. Just run: showNewsListThenMarkFavoriteWillAlsoUpdateNewsList(...).blockinAwait()
// Also note that since our test are push based (rxjava) we don't need any idling resources or other work arounds to ensure
// that the testing thread isn't faster than the components under test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment