Lab 7 - Testing a Shopping Basket App
Our starting point is an existing application for managing a shopping basket. Download Android Studio project here
Key classes we will be testing, their expected behaviour:
- ShoppingItem
- has properties: name, price, quantity.
- Price is assigned from string length of item name.
- has method getReformattedName(), which transforms names consisting only of numbers ( such as product code) by prefixing them with "Product #", for example, the name "12345" becomes "Product #12345"
- ShoppingBasket - a collection of ShoppingItems
- Provides methods such as size(), add()
- When adding a ShoppingItem with a name already contained in the basket, instead of adding a new element to the list, the previously existing ShoppingItems quantity gets updated
- getTotal(): Int -- should return the total cost of all items in the basket (taking into account quantity)
- BasketStorageHelper - Helper to save/load ShoppingBasket objects to/from SharedPreferences.
- saveBasket(basket: ShoppingBasket, sharedPrefs: SharedPreferences) { ... }
- Before saving to SharedPreferences, transforms the ShoppingBasket into a json String representation.
- loadBasket(sharedPrefs: SharedPreferences) { ... }
- Parses the stored json representation into a ShoppingBasket. If no previous value exists in SharedPreferences, returns a new, empty ShoppingBasket
- saveBasket(basket: ShoppingBasket, sharedPrefs: SharedPreferences) { ... }
BasketStorageHelper is used to store the basket state to disk when app is closed (and reload it when re-opened)
Local Unit Tests
- Write local unit tests for the cases defined in ShoppingTests.kt
- Run the tests with code coverage, try to get 100% line coverage for ShoppingBasket class
Note: with a local unit test for ShoppingItems getReformattedName() method, you will get exceptions. This is expected.
- ShoppingBasket has one bug with respect to above defined app behaviour, see if you can find through a failing test case.
Roboelectric
Add Robolectric to your project. Roboelectric provides fake/mock implementations of the Android framework APIs for you so you can use them in your local unit tests.
in build.gradle:
- Inside android { .. } add:
testOptions { unitTests { includeAndroidResources = true } }
- In dependencies { .. } add:
androidTestImplementation 'androidx.test.ext:junit:1.1.1' testImplementation 'androidx.test:core:1.2.0' testImplementation "org.robolectric:robolectric:4.3.1"
If you get errors about problems of supporting SDK Create a new test class, annotate with @RunWith(RobolectricTestRunner::class), as below:
@RunWith(RobolectricTestRunner::class) class RoboelectricUnitTest { ... }
- With Roboelectric, you can test the getReformattedName() method mentioned previously.
Add more tests so that you have 100% coverage of lines for both ShoppingItem and ShoppingBasket
Instrumented Unit Tests
Complete the test cases defined in ExampleInstrumentedTest.kt (androidTest folder)
The idea is to validate the integration between BasketStorageHelper and SharedPreferences API.
- You can get a SharedPreferences object similar to how it is already done in MainActivity:
sharedPrefs = getSharedPreferences(MainActivity.APP_PREFS, MODE_PRIVATE)
- If a certain .json is already contained in SharedPreferences, does BasketStorageHelper read it in correctly?
- After calling BasketStorageHelper saveBasket(), is the data written into SharedPreferences in the expected format ?
Note: With our examples, the same test case would also work as a Roboelectric test!
Espresso UI tests
Add Espresso dependencies to your project: @@
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' androidTestImplementation 'androidx.test:rules:1.2.0'
@@ Create a new class for your Espresso tests (under instrumented tests):
@RunWith(AndroidJUnit4::class) class EspressoTests { @get:Rule var activityRule = ActivityTestRule<MainActivity>(MainActivity::class.java) }
The above defined rule starts (and closes) MainActivity for each test case.
- Create a new test case which:
- Checks that text "Milk" is not displayed/doesn't exist
- Performs typing on view with id
editText_name
, entering text "Milk" - Clicks the view with text "Add"
- Checks that view with text "Milk" is displayed (exists)
- Try writing some UI tests for other cases, e.g. readding same name twice.
Note: If you are running multiple tests that are affecting the same SharedPreferences, the tests may start failing since one test is affecting the state of another. Instead, it's a good idea to clear the state before each test.
private fun clearSharedPrefs() { val sharedPreferences = getInstrumentation().targetContext .getSharedPreferences(MainActivity.APP_PREFS, Context.MODE_PRIVATE) sharedPreferences.edit().clear().commit() }
The above code uses instrumentation to get a handle of SharedPreferences and clears it. You could use it at the beginning of each test case. But that introduces a lot of repetitive code. Instead, it's better to use rules to define such behaviour.
In this case, let's modify the ActivityTestRule so that it calls clearSharedPrefs():
@get:Rule var activityRule = object : ActivityTestRule<MainActivity>(MainActivity::class.java){ override fun beforeActivityLaunched() { clearSharedPrefs() super.beforeActivityLaunched() } }
Bonus: Test driven development
Test driven development is the practice of first designing and implementing the test cases and then implementing the necessary features to make the tests pass.
Let's add a feature where the basket price is given a 20% price discount if it contains 5 or more unique items. Additionally, in the UI, if the user has already added 4 items, a notification should show them that they are close to getting the discount.
The tests are implemented for you, try to update the app implementation to make the tests pass. We have one local unit test and one Espresso test.
Local unit test:
@Test fun fiveOrMoreUniqueItemsGives20PercentDiscount(){ // TODO: Make me pass (implement missing feature) val basket = ShoppingBasket() repeat(5){ basket.addItem(ShoppingItem("Item$it")) } val fullPrice = basket[0].price * 5 val expectedDiscountPrice = (fullPrice * 0.8).toInt() // -20% discount assertEquals(expectedDiscountPrice, basket.getTotal()) }
Espresso test:
@Test fun addingFourUniqueItemsShowsDiscountHint() { // TODO: Make me pass (implement missing feature) onView(withText("Add 1 more item to get 20% off!")).check(doesNotExist()) //Add 4 items repeat(4){ onView(withId(R.id.editText_name)) .perform(typeText("Product$it"), closeSoftKeyboard()) onView(withId(R.id.button)).perform(click()) } onView(withText("Add 1 more item to get 20% off!")).check(matches(isDisplayed())) }