ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Chapter9: Testing the persistence layer
    프로그래밍/Unit test 2023. 3. 27. 15:47
    반응형

    Android_Test-Driven_Development - Lance_Gleason_Victoria_Gonda_Fern 공부중


     

    In most apps you’ll build, you will store data in one way or another. It might be in shared preferences, in a database, or otherwise. No matter which way you’re saving it, you need to be confident it is always working. If a user takes the time to put together content and then loses it because your persistence code broke, both you and your user will have a sad day.

    You have the tools for mocking out a persistence layer interface from Chapter 7, “Introduction to Mockito.” In this chapter you will take it a step further, testing that when you interact with a database, it behaves the way you expect.  

    • How to use TDD to have a well-tested RoomDB database.
    • Why persistence testing can be difficult.
    • Which parts of your persistence layer should you test.

     

    Getting started

    This app provides a place where you can keep track of the wishlists and gift ideas for all your friends and loved ones.

    AS-IS TO-BE
     
     
     

    You can add a name, but it will be gone next time you open the app! You will implement the persistence layer to save the wishlist in this chapter.

     

    Exploring the project

    There are a couple of files you should be familiar with before getting started.

    • WishlistDao.kt: Is the database access object. You will work on defining the database interactions in this class. This is also the class you will write your tests for. Notice that right now the Dao interactions are stubbed out in WishlistDaoImpl.
    • RepositoryImpl.kt: This class should be familiar to you from Chapter 8, “Integration.” It’s the repository that hooks your app up with the database.
    • KoinModules.kt: This handles the dependency injection for the app, specifying how to create any dependencies.
    • StringListConverter.kt: This is a helper object to convert lists of Strings to a single String and back again to store in the database.

     

    Setting up the test class

    As with any test, the first thing you need to do is create the file.

    @RunWith(AndroidJUnit4::class)
    class WishlistDaoTest {
    }

    Continuing your set up, add the following test rule to your test class:

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    Android Architecture Components uses an asynchronous background executor to do its work. InstantTaskExecutorRule is a rule that swaps out that executor and replaces it with a synchronous one. This will make sure that, when you’re using LiveData, it’s all run synchronously in the tests.

    You also need to create the properties to hold your WishlistDatabase and WishlistDao. Add these properties to your test class now:

    private lateinit var wishlistDatabase: WishlistDatabase
    private lateinit var wishlistDao: WishlistDao

     

    The WishlistDao is what you are performing your tests on. To create an instance of this class, you’ll need an instance of the WishlistDatabase first. You will initialize these in a @Before block in a moment.

     

    Setting up the database

    Start by opening WishlistDatabase.kt and add the following abstract method:

    @Database(entities = [Wishlist::class], version = 1)
    @TypeConverters(StringListConverter::class)
    abstract class WishlistDatabase : RoomDatabase() {
      abstract fun wishlistDao(): WishlistDao
    }

    This tells the Database to look for and build the WishlistDao. Then, open WishlistDao.kt and annotate the interface with @Dao to round out the connections. 

    @Dao
    interface WishlistDao {
      // TODO Adds query functions
    }

     

    더보기

    @TypeConverters

     

    Sqlite supports only primitive types. When it needs to use Object type, we can try @TypeConverters like this: 

    object StringListConverter {
    
        @TypeConverter
        @JvmStatic
        fun stringListToString(list: MutableList<String>?): String? =
            list?.joinToString(separator = "|")
    
        @TypeConverter
        @JvmStatic
        fun stringToStringList(string: String?): MutableList<String>? =
            string?.split("|")?.toMutableList()
      }

    The converter will be used when saving, and getting entities.

    @Entity
    data class Wishlist(
        val receiver: String,
        val wishes: List<String>,
        @PrimaryKey(autoGenerate = true)
        var id: Int = 0
    )

    When inserting values, StringListConverter will be used like this:

    public WishlistDao_Impl(RoomDatabase __db) {
        this.__db = __db;
        this.__insertionAdapterOfWishlist = new EntityInsertionAdapter<Wishlist>(__db) {
          @Override
          public String createQuery() {
            return "INSERT OR REPLACE INTO `Wishlist` (`receiver`,`wishes`,`id`) VALUES (?,?,nullif(?, 0))";
          }
    
          @Override
          public void bind(SupportSQLiteStatement stmt, Wishlist value) {
            if (value.getReceiver() == null) {
              stmt.bindNull(1);
            } else {
              stmt.bindString(1, value.getReceiver());
            }
            final String _tmp = StringListConverter.INSTANCE.stringListToString(value.getWishes());
            if (_tmp == null) {
              stmt.bindNull(2);
            } else {
              stmt.bindString(2, _tmp);
            }
            stmt.bindLong(3, value.getId());
          }
        };
      }

     

    https://developer.android.com/training/data-storage/room/referencing-data?hl=ko 

     

    Using an in-memory database

    One of the challenges that make writing persistence tests difficult is managing the state before and after the tests run. You’re testing saving and retrieving data, but you don’t want to end your test run with a bunch of test data on your device or emulator.

    How can you save data while your tests are running, but ensure that test data is gone when the tests finish? You could consider erasing the whole database, but if you have your own non-test data saved in the database outside of the tests, that would delete too.

    You can solve this problem by using an in-memory database. RoomDB luckily provides a way to easily create one. Add this to your test class:

    @Before
    fun initDb() {
      // 1
      wishlistDatabase = Room.inMemoryDatabaseBuilder(
          ApplicationProvider.getApplicationContext(),
          WishlistDatabase::class.java).build()
      // 2
      wishlistDao = wishlistDatabase.wishlistDao()
    }
    1. Here you’re using a RoomDB builder to create an in-memory WishlistDatabase. Compare this to the database creation you’ll add to KoinModules.kt at the end of this chapter. Information stored in an in-memory database disappears when the tests finish, solving your state issue.
    2. You then use this database to get your WishlistDao.

    Almost done setting up! After your tests finish, you also need to close your database. Add this to your test class:

    @After
    fun closeDb() {
      wishlistDatabase.close()
    }

     

    Writing a test

    Test number one is going to test that when there’s nothing saved, getAll() returns an empty list. This is a function for fetching all of the wishlists from the database. Add the following test, using the imports androidx.lifecycle.Observer for Observer, and org.mockito.kotlin.* for mock() and verify():

    @Test
    fun getAllReturnsEmptyList() {
      val testObserver: Observer<List<Wishlist>> = mock()
      wishlistDao.getAll().observeForever(testObserver)
      verify(testObserver).onChanged(emptyList())
    }

    This tests the result of a LiveData response similar to how you wrote your tests in Chapter 7, “Introduction to Mockito.” You create a mock Observer, observe the LiveData returned from getAll() with it, and verify the result is an empty list.

    @Dao
    interface WishlistDao {
      fun getAll(): LiveData<List<Wishlist>>
    }

    There's a compiler error.

    Try running it with this change.

    @Dao
    interface WishlistDao {
      @Query("")
      fun getAll(): LiveData<List<Wishlist>>
    }

    The compiler enforces that you include a query in the parameter.

    That means the next step is to fill in the query as simply as possible. Fill in your query with the following:

    @Query("SELECT * FROM wishlist")

    Finally, RoomDB requires all the abstract methods in the DAO class to have annotations, so go ahead and add the following incorrect annotations to the other two methods.

    @Query("SELECT * FROM wishlist WHERE id != :id")
    fun findById(id: Int): LiveData<Wishlist>
    @Delete
    fun save(vararg wishlist: Wishlist)

    Run your test and it finally compiles! But... it’s passing. When practicing TDD you always want to see your tests fail first. You never saw a state where the test was compiling and failing. You were so careful to only add the smallest bits until it compiled. RoomDB made it hard to write something that didn’t work.

     

    Knowing not to test the library (Failing test에 얽메여 라이브러리를 고치려하지 말자)

    The fact that RoomDB made it hard to write a failing test is a clue. When your tests align closely with a library or framework, you want to be sure you’re testing your code and not the third-party code. If you really want to write tests for that library, you might be able to contribute to the library, if it’s an open-source project. ;]

    In this case, it’s not up to you to test the RoomDB framework. It’s those writing RoomDB’s responsibility to make sure that when the database is empty, it returns nothing. Instead, you want to test that your logic, your queries, and the code that depends on them are working correctly.

    With that in mind, you can move on to test other database interactions.

     

    Testing an insert

    With any persistence layer, you need to be able to save some data and retrieve it. That’s exactly what your next test will do. Add this test to your class:

    @Test
    fun saveWishlistsSavesData() {
      // 1
      val wishlist1 = Wishlist("Victoria", listOf(), 1)
      val wishlist2 = Wishlist("Tyler", listOf(), 2)
      wishlistDao.save(wishlist1, wishlist2)
      // 2
      val testObserver: Observer<List<Wishlist>> = mock()
      wishlistDao.getAll().observeForever(testObserver)
      // 3
      val listClass = ArrayList::class.java as Class<ArrayList<Wishlist>>
      val argumentCaptor = ArgumentCaptor.forClass(listClass)
      // 4
      verify(testObserver).onChanged(argumentCaptor.capture())
      // 5
      assertTrue(argumentCaptor.value.size > 0)
    }
    1. Create a couple of wishlists and save them to the database. At this point save() does not exist yet, so there will be an error.
    2. Use your mock testObserver again to call getAll().
    3. Create an ArgumentCaptor to capture the value in onChanged(). Using an ArgumentCaptor from Mockito allows you to make more complex assertions on a value than equals().
    4. Use verify method to capture the argument passed to the onChanged() method. 
    5. Test that the result from the database is a non-empty list. At this point you care
    6. that data was saved and not what was saved, so you’re checking the list size only.

    Great! Remember, this is currently the save() function you have in WishlistDao:

    @Delete
    fun save(vararg wishlist: Wishlist)

    You need to have a database interaction annotation for this to compile, as you learned earlier in this chapter. You also want to see this test failing, so you’re using the wrong one, @Delete. Run your test and see it fails.

     

    Making your test pass

    This one is simple enough to make it pass. Just change the @Delete annotation with an @Insert. Your save() signature should now look like this:

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun save(vararg wishlist: Wishlist)

    Run your tests, and they should all be green.

     

    Testing your query

    Now that you have a way to save data in your database, you can test your getAll() query for real! Add this test:

    @Test
    fun getAllRetrievesData() {
      val wishlist1 = Wishlist("Victoria", emptyList(), 1)
      val wishlist2 = Wishlist("Tyler", emptyList(), 2)
      wishlistDao.save(wishlist1, wishlist2)
      
      val testObserver: Observer<List<Wishlist>> = mock()
      wishlistDao.getAll().observeForever(testObserver)
      
      val listClass = ArrayList::class.java as Class<ArrayList<Wishlist>>
      val argumentCaptor = ArgumentCaptor.forClass(listClass)
      verify(testObserver).onChanged(argumentCaptor.capture())
      
      val capturedArgument = argumentCaptor.value
      assertTrue(capturedArgument
          .containsAll(listOf(wishlist1, wishlist2)))
    }

    This is almost the same as your previous test except for the final line. In that line, you’re testing that the list result contains the exact wishlists you expect.

    Build and run your tests. It may come as a surprise, but they failed! Why is that? Insert a debugger breakpoint on the assertion line and inspect the capturedArgument at that point when you run it again, using the debugger.

    Huh! Somehow there is a list with an empty string in it.

     

    Fixing the bug

    How could this happen? StringListConverter holds the key. Take a look at the object. In stringToStringList(), when there is an empty String saved in the database, as is the case for an empty list, the split function used returns a list with an empty string in it!

        @TypeConverter
        @JvmStatic
        fun stringToStringList(string: String?): MutableList<String>? =
            string?.split("|")?.toMutableList() // "" -> [""]

    Now that you know the problem, you can solve it.

        @TypeConverter
        @JvmStatic
        fun stringToStringList(string: String?): MutableList<String> =
            if (!string.isNullOrBlank()) string.split("|").toMutableList()
            else mutableListOf()

     

    Testing a new query

    Moving on. In your database you also need the ability to retrieve an item by id. To create this functionality, start by adding a test for it:

    @Test
    fun findByIdRetrievesCorrectData() {
      // 1
      val wishlist1 = Wishlist("Victoria", emptyList(), 1)
      val wishlist2 = Wishlist("Tyler", emptyList(), 2)
      wishlistDao.save(wishlist1, wishlist2)
      // 2
      val testObserver: Observer<Wishlist> = mock()
      wishlistDao.findById(wishlist2.id).observeForever(testObserver)
      verify(testObserver).onChanged(wishlist2)
    }
    1. Create and save some wishlists, same as your other tests.
    2. Query for a specific wishlist, wishlist2, and verify the result is correct.

    As a reminder, this is what you have in WishlistDao:

    @Query("SELECT * FROM wishlist WHERE id != :id")
    fun findById(id: Int): LiveData<Wishlist>

    Notice it’s intentionally incorrect. It’s searching for a wishlist where the id is not the given id. This is again to make sure you see a failing test.

    Run that test and verify it really does fail.

    Ready? Run your tests to see them all pass.

    @Query("SELECT * FROM wishlist WHERE id = :id")

     

    In this chapter, you learned hands-on how to handle the statefulness of your tests using an in-memory database. You need this set up and tear down to write reliable, repeatable persistence tests.

     

    Key points

    • Persistence tests help keep your user’s data safe.
    • Statefulness(test succeed, fail) can make persistence tests difficult to write. (DB에 테스트 케이스를 접목했을 때 fail 내기 힘들었던 점, 테스트가 반복될 때마다 데이터가 쌓일 수 있었다는 점)
    • You can use an in-memory database to help handle stateful tests.
    • You need to include both set up (@Before) and tear down (@After) with persistence tests.
    • Be careful to test your code and not the library or framework you’re using.
    • Sometimes you need to write ”broken” code first to ensure that your tests fail.
    • If the persistence library you’re using doesn’t have built-in strategies for testing, you may need to delete all persisted data before and after each test.

     

     

    반응형

    '프로그래밍 > Unit test' 카테고리의 다른 글

    Chapter 4: The Testing Pyramid  (0) 2023.02.02
Designed by Tistory.