Skip to content

Mockable database layer for app-level integration tests #121

Description

@Gabriel-Pereira1788

Contexto

Hoje um app consumidor (dayone-expo) não tem como escrever testes de integração "tela inteira" (render real + fireEvent + waitFor em texto/UI real, no estilo do que já existe num app irmão, DayOne legado, usando um repositório in-memory trocado via DI) porque @salve-software/react-native-salve-db fala direto com o Nitro HybridObject nativo (SQLite real via JSI/C++) — não existe hoje nenhum seam oficial pra substituir isso por um banco fake/em memória sob Jest.

Investigando dois bugs recentes (issues #120 e a discussão de fuso horário no dayone-expo) ficou claro que testes de integração de verdade — que exercitem reatividade (useQuery/subscribeToChanges) e sync (incluindo falha de rede) de ponta a ponta — teriam pego os dois problemas mais rápido do que a investigação manual que fizemos.

Objetivo desta issue: decidir como dar a apps consumidores uma forma de mockar a camada de banco pra testes de integração bem elaborados, sem perder fidelidade de comportamento (principalmente a reatividade nativa, que é a proposta de valor central da lib).

Referência de padrão existente (não é 1:1 aplicável, mas informa o desenho)

O app DayOne-App/DayOne (legado, Supabase) resolve isso com:

  • Um contrato de repositório (IBaseRepository<T>: get/findById/create/update/delete/findBy/on) com um hook setMock?(data: T[]).
  • Uma implementação in-memory (Map global) satisfazendo o mesmo contrato.
  • Troca via DI Container (DIProvider) — a implementação inteira é substituída uma vez, no setup do teste.
  • Testes de integração fazem renderApp() (árvore real do Expo Router), seedam via setMock, e usam fireEvent/waitFor contra UI real.

Diferença estrutural importante: no DayOne, a reatividade da UI vem do React Query (invalidateQueries manual em cada onSuccess de mutation) — totalmente desacoplada da persistência. Por isso o on() do repositório é um no-op stub e o mock não precisa reproduzir nenhuma notificação de mudança.

No react-native-salve-db, a reatividade (useQuery + subscribeToChanges) é nativa, movida por sqlite3_update_hook em C++, cruzando JSI. Um fake que só imita CRUD (tipo o InAppRepositoryBuilder do DayOne) reproduziria dados mas perderia exatamente a característica que diferencia esta lib — e que já causou/expôs os dois bugs recentes. Qualquer proposta escolhida precisa preservar essa reatividade de verdade, não just mockar dados estáticos.

Ponto técnico já levantado que qualquer proposta deve considerar: o único seam hoje pelo qual TODO o src/ obtém a "conexão nativa" é

// src/database/Database.class.ts:7
const _bridge = NitroModules.createHybridObject<SalveDatabase>('SalveDatabase');

Se a implementação escolhida devolver, sob Jest, um objeto que satisfaça a interface SalveDatabase (src/specs/SalveDatabase.nitro.ts, 12 métodos: configure, registerSchema, reset, logout, execute, beginTransaction, commit, rollback, triggerSync, triggerSyncAll, subscribeToChanges, unsubscribeFromChanges, debugPreparedStatementCount), nenhum outro arquivo em src/ (query builders, QueryCache, useQuery, syncTrigger) precisa mudar — todos recebem/consomem esse bridge por injeção ou pelo singleton Database.

As 3 propostas a avaliar

Proposta A — Fake JS puro na borda do bridge

Implementar em JS/TS uma classe que satisfaça a interface SalveDatabase, com um mini-motor de query hand-rolled (filtro por igualdade/comparação, sem SQL de verdade) reimplementando where/orderBy/limit manualmente, e uma notificação de mudança simulada em memória (equivalente ao sqlite3_update_hook, mas escrito à mão).

  • Prós: superfície pequena, rápido de prototipar, zero dependência nativa nova, roda em qualquer ambiente Jest sem toolchain C++.
  • Contras: reimplementa em JS semântica que já existe em C++ (geração de SQL, migrations com ADD COLUMN, triggers de sync, _sync_apply_lock) — risco real de divergência entre "o que o mock aceita" e "o que o SQLite real aceita", exatamente o tipo de falso positivo/negativo que testes de integração deveriam evitar. Precisa reimplementar a notificação de mudança à mão (não é trivial fazer isso fielmente).

Proposta B — Motor SQL real embutido (WASM) atrás da mesma interface

Igual à A na forma de injeção, mas o execute() do fake roda contra um SQLite de verdade compilado pra WASM (ex. sql.js/wa-sqlite), rodável em Jest/Node sem compilação nativa. where/orderBy/limit/joins passam a ser fiéis (é SQL de verdade). Ainda seria necessário portar/duplicar em JS a geração de DDL de migrations e triggers de sync (hoje só existe em C++, em MigrationEngine.cpp), e reimplementar a notificação de mudança (WASM SQLite não expõe um hook equivalente ao sqlite3_update_hook do jeito que a lib usa hoje — precisa verificar).

  • Prós: fidelidade de execução de SQL muito maior que A; sem toolchain nativo pro consumidor (WASM roda em qualquer Node).
  • Contras: ainda duplica a lógica de schema→DDL (migrations/triggers) em dois lugares (C++ e JS) — risco de divergência menor que A, mas real. Nova dependência (sql.js ou similar).

Proposta C — Binding Node-API do core C++ real, sem passar por Nitro/JSI

Compilar o núcleo de domínio já existente (cpp/database/*, cpp/query/QueryExecutor.cpp, cpp/sync/*, cpp/expression/*, cpp/credentials/*, cpp/http/*, sqlite3.c vendorizado) como um addon Node-API (N-API, ex. via node-addon-api), com uma casca nova (NodeSalveDatabase.cpp, análoga a HybridSalveDatabase.cpp mas falando N-API em vez de JSI) implementando os mesmos 12 métodos do spec direto contra QueryExecutor/SyncOrchestrator/DatabaseManager::shared(). cpp/platform/platform.hpp (diretório de docs, secure storage, HTTP, log) já tem uma implementação de teste pronta e reaproveitável como está (cpp/tests/support/platform_test.cpp — inclusive já expõe platform::test::setHttpExecuteResult, o mock de rede que faltou na investigação da #120).

  • Prós: fidelidade máxima — é literalmente o mesmo motor de banco, migrations, triggers e sync que roda em produção; zero duplicação de lógica de domínio; reatividade real (o mesmo sqlite3_update_hook); dá pra expor o mock de HTTP nativo (setHttpExecuteResult) direto pro JS de teste, permitindo scriptar cenários de rede (o que teria acelerado a investigação da useQuery never reflects a local write when its write-triggered sync push fails offline #120).
  • Contras: maior custo de engenharia — novo alvo/toolchain de build (node-gyp/cmake-js), necessidade de decidir entre exigir toolchain C++ local no consumidor ou publicar binários pré-compilados por SO/arch (prebuildify), e um modelo de threading novo pro projeto: Promise<T> do spec vira napi_deferred resolvido em background, e o callback de subscribeToChanges precisa de napi_threadsafe_function pra ser invocado a partir de threads nativas de sync (padrão N-API conhecido, mas código novo, sem precedente no repo hoje). Cria uma 3ª plataforma-alvo do core (ao lado de iOS/Android) que precisa entrar na disciplina de manutenção — sugestão: parametrizar um subconjunto dos testes de cpp/tests/* pra rodar contra os dois bindings (JSI real e N-API de teste) e evitar divergência silenciosa entre eles.

O que fica pra quem pegar a issue

  • Avaliar as 3 propostas acima (ou propor uma quarta, se aparecer algo melhor) e documentar a decisão nesta issue antes de implementar — incluindo prova de conceito mínima se necessário pra desempatar entre B e C.
  • Definir o escopo do V1: cobrir os 12 métodos do spec de uma vez, ou começar por CRUD + subscribeToChanges (cobre o caso de uso central de UI reativa) e deixar sync (triggerSync/triggerSyncAll) pra uma fase 2.
  • Se a escolha envolver um binding nativo novo (B ou C), decidir toolchain-local-vs-binário-pré-compilado e o que isso implica pra CI.
  • Entregar como um subpath instalável pelo app consumidor (ex. @salve-software/react-native-salve-db/testing) com um exemplo mínimo de jest.mock('react-native-nitro-modules', ...) redirecionando createHybridObject('SalveDatabase') pra a implementação de teste escolhida — esse é o único ponto de injeção necessário, src/ não precisa mudar independente da proposta escolhida.

Critério de aceite

  • Um app consumidor (ex. dayone-expo) consegue escrever um teste de integração estilo dashboard.integration.test.ts do DayOne (render de tela real via Testing Library + fireEvent + waitFor) contra um Database/useQuery real da lib, sem tocar SQLite/JSI de verdade, com reatividade (useQuery reage a uma escrita) funcionando de verdade — não simulada manualmente no teste.
  • Documentação de como configurar o mock no app consumidor (pelo menos um exemplo jest.setup.ts).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions