Тестирование асинхронных операций

Асинхронная природа localForage оказывает непосредственное влияние на организацию тестов. В отличие от обычного localStorage, где большинство операций выполняется синхронно, localForage предоставляет API на основе Promise и может использовать различные механизмы хранения данных, включая IndexedDB, WebSQL и LocalStorage.

При тестировании необходимо учитывать несколько особенностей:

  • операции чтения и записи выполняются асинхронно;
  • возможны ошибки доступа к хранилищу;
  • требуется ожидание завершения операций;
  • данные могут сохраняться между тестами при неправильной очистке окружения;
  • в тестах часто используется мокирование хранилища.

Основная цель тестирования заключается в проверке корректности взаимодействия приложения с локальным хранилищем независимо от используемого драйвера.


Проверка базовых операций чтения и записи

Наиболее распространённая категория тестов связана с сохранением и извлечением данных.

Пример функции:

import localforage from "localforage";

export async function saveUser(user) {
    await localforage.setItem("user", user);
}

export async function loadUser() {
    return localforage.getItem("user");
}

Тест с использованием Jest:

import localforage from "localforage";
import { saveUser, loadUser } from "./userStorage";

describe("user storage", () => {
    beforeEach(async () => {
        await localforage.clear();
    });

    test("should save user", async () => {
        const user = {
            id: 1,
            name: "John"
        };

        await saveUser(user);

        const stored = await localforage.getItem("user");

        expect(stored).toEqual(user);
    });

    test("should load user", async () => {
        const user = {
            id: 1,
            name: "John"
        };

        await localforage.setItem("user", user);

        const result = await loadUser();

        expect(result).toEqual(user);
    });
});

Здесь используется ключевой принцип тестирования асинхронного кода — ожидание завершения операций через await.


Использование async/await в тестах

Современные тестовые фреймворки позволяют объявлять тестовые сценарии асинхронными.

Правильный вариант:

test("loads data", async () => {
    await localforage.setItem("theme", "dark");

    const theme = await localforage.getItem("theme");

    expect(theme).toBe("dark");
});

Неправильный вариант:

test("loads data", () => {
    localforage.setItem("theme", "dark");

    const theme = localforage.getItem("theme");

    expect(theme).toBe("dark");
});

Во втором примере сравнивается Promise вместо результата операции, что приводит к ошибкам и ложным результатам тестирования.


Использование возвращаемых Promise

Некоторые проекты предпочитают стиль тестирования без async/await.

Пример:

test("stores value", () => {
    return localforage
        .setItem("language", "ru")
        .then(() => localforage.getItem("language"))
        .then(value => {
            expect(value).toBe("ru");
        });
});

Jest ожидает завершения Promise и корректно обрабатывает результат теста.


Проверка удаления данных

Удаление является важной частью работы с локальным хранилищем.

Функция:

export async function removeUser() {
    await localforage.removeItem("user");
}

Тест:

test("removes user", async () => {
    await localforage.setItem("user", {
        id: 1
    });

    await removeUser();

    const user = await localforage.getItem("user");

    expect(user).toBeNull();
});

Тест подтверждает, что после удаления данные больше недоступны.


Тестирование очистки хранилища

Метод clear() удаляет все записи текущего экземпляра localForage.

Пример:

test("clears storage", async () => {
    await localforage.setItem("a", 1);
    await localforage.setItem("b", 2);

    await localforage.clear();

    const a = await localforage.getItem("a");
    const b = await localforage.getItem("b");

    expect(a).toBeNull();
    expect(b).toBeNull();
});

Проверка существования ключей

При использовании метода keys() удобно тестировать структуру хранилища.

test("returns keys", async () => {
    await localforage.setItem("user", {});
    await localforage.setItem("settings", {});

    const keys = await localforage.keys();

    expect(keys).toContain("user");
    expect(keys).toContain("settings");
});

Проверка количества записей

Метод length() возвращает число сохранённых элементов.

test("counts records", async () => {
    await localforage.clear();

    await localforage.setItem("a", 1);
    await localforage.setItem("b", 2);

    const count = await localforage.length();

    expect(count).toBe(2);
});

Такие проверки особенно полезны при реализации кэшей.


Тестирование ошибок

Асинхронные операции могут завершаться исключениями.

Рассмотрим функцию:

export async function saveSettings(settings) {
    try {
        await localforage.setItem("settings", settings);

        return true;
    } catch {
        return false;
    }
}

Тест ошибки:

import localforage from "localforage";

test("handles storage errors", async () => {
    jest
        .spyOn(localforage, "setItem")
        .mockRejectedValue(new Error("Storage error"));

    const result = await saveSettings({
        theme: "dark"
    });

    expect(result).toBe(false);
});

Здесь происходит искусственная генерация ошибки через мок.


Проверка выбрасываемых исключений

Если функция не обрабатывает ошибку самостоятельно, необходимо проверить факт возникновения исключения.

Функция:

export async function writeData(data) {
    return localforage.setItem("data", data);
}

Тест:

test("throws storage error", async () => {
    jest
        .spyOn(localforage, "setItem")
        .mockRejectedValue(new Error("Disk full"));

    await expect(
        writeData({})
    ).rejects.toThrow("Disk full");
});

Конструкция rejects.toThrow() предназначена специально для Promise-ошибок.


Изоляция тестов

Каждый тест должен работать независимо от других.

Типичная подготовка окружения:

beforeEach(async () => {
    await localforage.clear();
});

Иногда дополнительно используется:

afterEach(() => {
    jest.restoreAllMocks();
});

Это предотвращает влияние моков на последующие тестовые сценарии.


Создание отдельного экземпляра localForage

Использование глобального экземпляра может приводить к конфликтам между тестами.

Лучшей практикой считается создание собственного экземпляра:

const storage = localforage.createInstance({
    name: "test-db"
});

Тест:

test("stores value in instance", async () => {
    await storage.setItem("counter", 10);

    const value = await storage.getItem("counter");

    expect(value).toBe(10);
});

Такой подход облегчает изоляцию данных.


Мокирование localForage

В модульных тестах зачастую не требуется реальное взаимодействие с IndexedDB.

Пример полного мока:

jest.mock("localforage", () => ({
    setItem: jest.fn(),
    getItem: jest.fn(),
    removeItem: jest.fn(),
    clear: jest.fn()
}));

Настройка поведения:

localforage.getItem.mockResolvedValue({
    id: 10
});

Проверка вызова:

expect(localforage.getItem)
    .toHaveBeenCalledWith("user");

Подобный подход делает тесты быстрыми и предсказуемыми.


Частичное мокирование

Иногда требуется заменить только одну функцию.

jest.spyOn(localforage, "getItem")
    .mockResolvedValue({
        id: 100
    });

Остальной API продолжает работать в штатном режиме.


Проверка количества вызовов

Тесты могут контролировать корректность использования API localForage.

const spy = jest.spyOn(
    localforage,
    "setItem"
);

await saveUser({
    id: 1
});

expect(spy).toHaveBeenCalledTimes(1);

Проверка аргументов:

expect(spy).toHaveBeenCalledWith(
    "user",
    {
        id: 1
    }
);

Тестирование последовательности операций

В сложных сценариях важно проверять порядок вызовов.

Пример функции:

export async function replaceUser(user) {
    await localforage.removeItem("user");
    await localforage.setItem("user", user);
}

Тест:

test("replaces user", async () => {
    const removeSpy =
        jest.spyOn(localforage, "removeItem");

    const setSpy =
        jest.spyOn(localforage, "setItem");

    await replaceUser({
        id: 5
    });

    expect(removeSpy).toHaveBeenCalled();
    expect(setSpy).toHaveBeenCalled();
});

Подобные проверки гарантируют соблюдение бизнес-логики.


Тестирование параллельных операций

Асинхронность позволяет выполнять несколько запросов одновременно.

Функция:

export async function saveBatch(data) {
    return Promise.all(
        data.map(item =>
            localforage.setItem(
                item.key,
                item.value
            )
        )
    );
}

Тест:

test("saves multiple records", async () => {
    const records = [
        {
            key: "a",
            value: 1
        },
        {
            key: "b",
            value: 2
        }
    ];

    await saveBatch(records);

    expect(
        await localforage.getItem("a")
    ).toBe(1);

    expect(
        await localforage.getItem("b")
    ).toBe(2);
});

Проверка работы с большими объектами

localForage часто используется для хранения сложных структур данных.

test("stores nested objects", async () => {
    const data = {
        user: {
            id: 1,
            profile: {
                name: "John",
                age: 30
            }
        },
        permissions: [
            "read",
            "write"
        ]
    };

    await localforage.setItem(
        "data",
        data
    );

    const result =
        await localforage.getItem("data");

    expect(result).toEqual(data);
});

Тест подтверждает корректную сериализацию и десериализацию структуры.


Тестирование бинарных данных

Одним из преимуществ localForage является поддержка Blob, ArrayBuffer и TypedArray.

Пример:

test("stores binary data", async () => {
    const bytes =
        new Uint8Array([1, 2, 3, 4]);

    await localforage.setItem(
        "file",
        bytes
    );

    const result =
        await localforage.getItem("file");

    expect(Array.from(result))
        .toEqual([1, 2, 3, 4]);
});

Подобные проверки актуальны для офлайн-приложений, систем кэширования изображений и хранения файлов.


Интеграционные тесты

Интеграционные тесты проверяют взаимодействие между несколькими компонентами приложения и localForage.

Пример сценария:

await loginUser();

const cachedUser =
    await localforage.getItem("user");

expect(cachedUser.id)
    .toBeDefined();

Здесь тестируется не только работа localForage, но и корректность бизнес-процесса авторизации.


Типичные проблемы при тестировании асинхронных операций

Отсутствие await

Наиболее частая ошибка:

localforage.setItem("a", 1);

expect(
    await localforage.getItem("a")
).toBe(1);

Запись может ещё не завершиться к моменту чтения.

Правильный вариант:

await localforage.setItem("a", 1);

expect(
    await localforage.getItem("a")
).toBe(1);

Общие данные между тестами

Если хранилище не очищается:

beforeEach(async () => {
    await localforage.clear();
});

то один тест может влиять на другой.

Неочищенные моки

После использования:

jest.spyOn(...)

необходимо выполнять:

jest.restoreAllMocks();

или

mockRestore();

Иначе поведение библиотеки будет изменено для последующих тестов.

Игнорирование ошибок Promise

Неправильно:

writeData({});

Правильно:

await writeData({});

или

return writeData({});

Только в этом случае тестовый фреймворк сможет корректно отследить завершение операции и возможные исключения.