Room. Тестування

У цьому уроці розглянемо, як тестувати Room. Напишемо кілька тестів для Dao і протестуємо міграцію.

Dao

У Dao ви прописуєте різні операції з Entity об'єктами: читання, вставка, зміна, видалення.

Приклад Dao:

@Dao
public interface EmployeeDao {
 
   @Query("SELECT * FROM employee")
   List<Employee> getAll();
 
   @Query("SELECT * FROM employee ORDER BY salary DESC")
   List<Employee> getAllOrderBySalary();
 
   @Insert
   void insert(Employee employee);
 
   @Insert
   void insertAll(List<Employee> employees);
 
   @Update
   int update(Employee employee);
 
   @Delete
   void delete(Employee employee);
 
   @Query("DELETE FROM employee")
   void deleteAll();
}

Для цих методів можна написати кілька тестів.

Я ж одразу покажу вміст тестового класу.

@RunWith(AndroidJUnit4.class)
public class EmployeeDaoTest {
 
   private AppDatabase db;
   private EmployeeDao employeeDao;
 
   @Before
   public void createDb() throws Exception {
       db = Room.inMemoryDatabaseBuilder(
               InstrumentationRegistry.getContext(),
               AppDatabase.class)
               .build();
       employeeDao = db.employeeDao();
 
   }
 
   @After
   public void closeDb() throws Exception {
       db.close();
   }
 
}

Зверніть увагу, що тест інструментальний. Тобто його треба буде запускати на пристрої або емуляторі.

У змінній db буде зберігатися база. При її створенні ми використовували метод inMemoryDatabaseBuilder. У результаті під час запуску тесту дані бази перебуватимуть у пам'яті та після завершення тесту будуть видалені.

У Before методі ми створюємо базу і Dao, а в After методі - закриваємо базу.

Розглянемо кілька можливих тестових методів

Вставляємо один запис і перевіряємо, що він же зчитувався.

@Test
public void whenInsertEmployeeThenReadTheSameOne() throws Exception {
   List<Employee> employees = EmployeeTestHelper.createListOfEmployee(1);
   
   employeeDao.insert(employees.get(0));
   List<Employee> dbEmployees = employeeDao.getAll();
   
   assertEquals(1, dbEmployees.size());
   assertTrue(EmployeeTestHelper.employeesAreIdentical(employees.get(0), dbEmployees.get(0)));
}

На допомогу собі я створив клас EmployeeTestHelper, який має кілька корисних методів:

  • createListOfEmployee створює список із зазначеною кількістю Employee об'єктів, заповнених рандомними даними
  • employeesAreIdentical перевіряє, що всі два зазначених Employee об'єкти рівні за всіма полями

Наступний тест перевірить, що під час виклику методу update запис має оновитися в базі.

@Test
public void whenUpdateEmployeeThenReadTheSameOne() throws Exception {
   List<Employee> employees = EmployeeTestHelper.createListOfEmployee(1);
   Employee employee = employees.get(0);
   employeeDao.insert(employee);
 
   employee.salary += 100;
   employee.name += " test";
   employeeDao.update(employee);
 
   List<Employee> dbEmployees = employeeDao.getAll();
   assertTrue(EmployeeTestHelper.employeesAreIdentical(employees.get(0), dbEmployees.get(0)));
}

Під час вставки кількох записів, усі вони мають опинитися в базі

@Test
public void whenInsertEmployeesThenReadThem() throws Exception {
   List<Employee> employees = EmployeeTestHelper.createListOfEmployee(5);
 
   employeeDao.insertAll(employees);
 
   assertEquals(5, employeeDao.getAll().size());
}

Метод deleteAll очищає всю базу.

@Test
public void whenDeleteAllThenReadNothing() throws Exception {
   List<Employee> employees = EmployeeTestHelper.createListOfEmployee(5);
   employeeDao.insertAll(employees);
 
   employeeDao.deleteAll();
 
   assertTrue(employeeDao.getAll().isEmpty());
}

Метод getAllOrderBySalary повинен повертати дані відсортовані за зарплатою

@Test
public void checkOrderBySalary() throws Exception {
   List<Employee> employees = EmployeeTestHelper.createListOfEmployee(5);
   employeeDao.insertAll(employees);
   Collections.sort(employees, new Comparator<Employee>() {
       @Override
       public int compare(Employee o1, Employee o2) {
           return o2.salary - o1.salary;
       }
   });
 
   assertEquals(employees, employeeDao.getAllOrderBySalary());
}

Щоб останній метод працював коректно, необхідно додати реалізацію методів equals і hashcode для Employee

@Entity()
public class Employee {
 
   @PrimaryKey
   public long id;
 
   public String name;
 
   public int salary;
 
   @Override
   public boolean equals(Object o) {
       if (this == o) return true;
       if (o == null || getClass() != o.getClass()) return false;
 
       Employee employee = (Employee) o;
 
       return id == employee.id;
   }
 
   @Override
   public int hashCode() {
       return (int) (id ^ (id >>> 32));
   }
 
}

Міграція

Розглянемо тестування міграції на простому прикладі. У нас є база версії 1 і Entity клас.

@Entity()
public class Employee {
 
   @PrimaryKey
   public long id;
 
   public String name;
 
   public int salary;
 
}

Ми додамо нове поле в цей клас, налаштуємо міграцію і створимо тест міграції.

Спочатку необхідно налаштувати експорт схеми вашої бази в json файли. Це робиться в build.gradle файлі модуля:

android {
   ...
   defaultConfig {
       ...
       javaCompileOptions {
           annotationProcessorOptions {
               arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
           }
       }
   }

Після компіляції застосунку в папці проєкту з'явиться папка schemas/<application_package>, у якій зберігатимуться схеми вашої бази даних. Поточна версія бази = 1. Для неї буде створено файл 1.json.

Вміст цього файлу являє собою поточну схему бази:

{
  "formatVersion": 1,
  "database": {
    "version": 1,
    "identityHash": "f644b5f11fc9422f1830daaaf37a190c",
    "entities": [
      {
        "tableName": "Employee",
        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `salary` INTEGER NOT NULL, PRIMARY KEY(`id`))",
        "fields": [
          {
            "fieldPath": "id",
            "columnName": "id",
            "affinity": "INTEGER",
            "notNull": true
          },
          {
            "fieldPath": "name",
            "columnName": "name",
            "affinity": "TEXT",
            "notNull": false
          },
          {
            "fieldPath": "salary",
            "columnName": "salary",
            "affinity": "INTEGER",
            "notNull": true
          }
        ],
        "primaryKey": {
          "columnNames": [
            "id"
          ],
          "autoGenerate": false
        },
        "indices": [],
        "foreignKeys": []
      }
    ],
    "setupQueries": [
      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"f644b5f11fc9422f1830daaaf37a190c\")"
    ]
  }
}

Давайте додамо нове поле в Employee. Це поле міститиме податковий клас співробітника. Клас може набувати значення 1,2 і 3 залежно від розміру зарплати. Будемо вважати, що у нас прогресивна шкала оподаткування )

Змінюємо версію бази в AppDatabase на 2. І в клас Employee додаємо поле taxclass:

@Entity()
public class Employee {
 
   @PrimaryKey
   public long id;
 
   public String name;
 
   public int salary;
 
   public int taxclass;
 
}

Компілюємо проєкт, і в папці schemas з'являється файл 2.json. Число 2 означає, що файл описує схему бази версії 2. Тобто в ній тепер буде інформація про поле taxclass.

У підсумку, в папці schemas у нас формується щось на кшталт журналу версій бази даних. Навіщо це потрібно, стане зрозуміло трохи пізніше.

Налаштовуємо міграцію. Детально про це я розповідав у минулому уроці. Тут зазначу лише, як виглядатиме Migration з першої на другу версію:

public static final Migration MIGRATION_1_2 = new Migration(1, 2) {
   @Override
   public void migrate(final SupportSQLiteDatabase database) {
       database.execSQL("ALTER TABLE employee ADD COLUMN taxclass INTEGER DEFAULT 0 NOT NULL");
       database.execSQL("UPDATE employee SET taxclass = 1 WHERE salary < 10000");
       database.execSQL("UPDATE employee SET taxclass = 2 WHERE salary BETWEEN 10000 AND 30000");
       database.execSQL("UPDATE employee SET taxclass = 3 WHERE salary > 30000");
   }
};

Тут ми додаємо нове поле в таблицю і налаштовуємо класи. Якщо зарплата менше 10000, то клас = 1. Якщо від 10000 до 30000, то 2. Якщо вище 30000, то 3.

Міграція готова. Під час запуску програми Room виконає перехід на другу версію бази. А ми зі свого боку можемо написати тест, який змоделює цей перехід. Тобто тест створить базу версії 1, заповнить її даними, виконає міграцію на версію 2 і перевірить, що все пройшло успішно.

Створюємо тест. У секцію dependencies додайте:

androidTestImplementation "android.arch.persistence.room:testing:1.0.0"

Це дасть нам доступ до інструменту тестування MigrationTestHelper.

А в секцію android додайте наступний sourceSets:

android {
    ...
    sourceSets {
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

Це дасть тесту доступ до папки Schemas, щоб він зміг зчитати схеми бази.

Тестовий клас:

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
 
   private static final String TEST_DB = "migration-test";
 
   @Rule
   public MigrationTestHelper helper;
 
   public MigrationTest() {
       helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
               AppDatabase.class.getCanonicalName(),
               new FrameworkSQLiteOpenHelperFactory());
   }
 
 
   @Test
   public void migrate1To2() throws IOException {
       SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
 
       db.execSQL("INSERT INTO employee VALUES (1, 'name 1', 5000)");
       db.execSQL("INSERT INTO employee VALUES (2, 'name 2', 10000)");
       db.execSQL("INSERT INTO employee VALUES (3, 'name 3', 20000)");
       db.execSQL("INSERT INTO employee VALUES (4, 'name 4', 30000)");
       db.execSQL("INSERT INTO employee VALUES (5, 'name 5', 35000)");
       db.close();
 
       db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);
 
       Cursor cursor = db.query("select * from employee");
       assertEquals(5, cursor.getCount());
       while (cursor.moveToNext()) {
           int salary = cursor.getInt(cursor.getColumnIndex("salary"));
           int taxClass = cursor.getInt(cursor.getColumnIndex("taxclass"));
 
           int expectedTaxClass = 0;
           if (salary < 10000) {
               expectedTaxClass = 1;
           } else if (salary <= 30000) {
               expectedTaxClass = 2;
           } else {
               expectedTaxClass = 3;
           }
           assertEquals("Wrong taxclass for salary: " + salary, expectedTaxClass, taxClass);
       }
   }
 
}

У конструкторі створюємо MigrationTestHelper. Він також буде використаний як Rule.

Розбираємо метод migrate1To2.

Спочатку ми методом createDatabase створюємо базу першої версії. Це можливо завдяки тому, що в папці schemas є файл 1.json і MigrationTestHelper за ним може створити базу.

Далі заповнюємо базу тестовими даними і закриваємо її. Закривати необхідно, тому що зараз структура бази буде змінюватися.

Метод runMigrationsAndValidate виконає міграцію бази на другу версію (виконавши код з MIGRATION_1_2) і перевірить, що отримана структура бази відповідає схемі з файлу 2.json.

Далі ми з нової отриманої бази читаємо дані по співробітниках і перевіряємо, що MIGRATION_1_2 відпрацював коректно і проставив працівникам правильні податкові класи. Для кожного співробітника ми самі за зарплатою обчислюємо податковий клас і звіряємо його з тим, який прийшов із бази.

Таким чином тест виконав міграцію бази і перевірив, що структура і дані були перетворені коректно.

Схеми

Одне невелике, але важливе зауваження про схеми в папці schemas. Вони генеруються під час компіляції проєкту, і тут треба бути уважними, тому що може вийти така ситуація:

  • є база версії 1 і, відповідно, файл 1.json
  • вирішуємо змінити структуру бази
  • додаємо нове поле в Entity клас, але забуваємо підняти версію бази
  • компілюємо проєкт і отримуємо в 1.json уже нову структуру бази
  • справжня схема версії 1 тепер загублена

Після цього міграційний тест не зможе створити базу першої версії, тому що 1.json описує вже другу версію.

Щоб уникнути цього, спочатку завжди піднімайте версію додатка в AppDatabase класі, а потім уже змінюйте структуру Entity класів.