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
Last active
August 26, 2019 15:44
-
-
Save sockeqwe/46134db906ac80faa698e25c3dde6ab0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
data class News( | |
val title : String | |
val favorite : Boolean | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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