Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/satsigner/satsigner/llms.txt

Use this file to discover all available pages before exploring further.

Overview

SatSigner uses Jest as its testing framework, with separate test suites for unit and integration testing. Tests are located in apps/mobile/tests/ and follow best practices for React Native and TypeScript projects.

Test Structure

tests/
├── __mocks__/          # Mock implementations
│   └── bip-321.ts
├── int/                # Integration tests
│   └── api/
│       ├── blockchain.test.ts
│       └── esplora.test.ts
└── unit/               # Unit tests
    ├── api/
    │   └── nostr.test.ts
    ├── store/
    │   └── nostr.test.ts
    └── utils/
        ├── address.test.ts
        ├── bip32.test.ts
        ├── bip321.test.ts
        ├── bip329.test.ts
        ├── bip39.test.ts
        └── bitcoin.test.ts

Jest Configuration

jest.config.js
/** @type {import('jest').Config} */

process.env.TZ = 'UTC'

const config = {
  preset: 'jest-expo',
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|bdk-rn|react-native-svg|uint8array-tools)'
  ],
  moduleNameMapper: {
    '^bip-321$': '<rootDir>/__mocks__/bip-321.ts'
  }
}

module.exports = config

Running Tests

package.json
# Run all tests (unit + integration)
yarn test

# Run only unit tests
yarn test:unit

# Run only integration tests
yarn test:int

# Run tests in watch mode
yarn test --watch

# Run tests with coverage
yarn test --coverage

Unit Testing

Testing Zustand Stores

Store tests verify state management logic:
tests/unit/store/nostr.test.ts
import { useNostrStore } from '@/store/nostr'
import { accountIds, nostrKeys, timestamps } from '../utils/nostr_samples'

// Mock MMKV storage
jest.mock('@/storage/mmkv', () => {
  const storage: Record<string, string> = {}
  return {
    __esModule: true,
    default: {
      setItem: jest.fn((name: string, value: string) => {
        storage[name] = value
      }),
      getItem: jest.fn((name: string) => storage[name] ?? null),
      removeItem: jest.fn((name: string) => {
        delete storage[name]
      })
    }
  }
})

// Mock utility functions
jest.mock('@/utils/nostr', () => ({
  generateColorFromNpub: jest.fn().mockResolvedValue('#ff5500')
}))

jest.mock('@/api/nostr', () => ({ NostrAPI: jest.fn() }))

describe('nostr store', () => {
  beforeEach(() => {
    // Reset store state before each test
    useNostrStore.setState({
      members: {},
      processedMessageIds: {},
      processedEvents: {},
      lastProtocolEOSE: {},
      lastDataExchangeEOSE: {},
      trustedDevices: {},
      activeSubscriptions: new Set(),
      syncingAccounts: {},
      transactionToShare: null
    })
  })

  describe('member management', () => {
    it('adds member with generated color', async () => {
      const { addMember, getMembers } = useNostrStore.getState()
      await addMember(accountIds.primary, nostrKeys.alice.npub)

      const members = getMembers(accountIds.primary)
      expect(members).toHaveLength(1)
      expect(members[0]).toEqual({
        npub: nostrKeys.alice.npub,
        color: '#ff5500'
      })
    })

    it('prevents duplicate members', async () => {
      const { addMember, getMembers } = useNostrStore.getState()
      await addMember(accountIds.primary, nostrKeys.alice.npub)
      await addMember(accountIds.primary, nostrKeys.alice.npub)

      expect(getMembers(accountIds.primary)).toHaveLength(1)
    })

    it('removes member', async () => {
      const { addMember, removeMember, getMembers } = useNostrStore.getState()
      await addMember(accountIds.primary, nostrKeys.alice.npub)
      await addMember(accountIds.primary, nostrKeys.bob.npub)

      removeMember(accountIds.primary, nostrKeys.alice.npub)

      const members = getMembers(accountIds.primary)
      expect(members).toHaveLength(1)
      expect(members[0].npub).toBe(nostrKeys.bob.npub)
    })

    it('isolates members between accounts', async () => {
      const { addMember, getMembers } = useNostrStore.getState()
      await addMember(accountIds.primary, nostrKeys.alice.npub)
      await addMember(accountIds.secondary, nostrKeys.bob.npub)

      expect(getMembers(accountIds.primary)).toHaveLength(1)
      expect(getMembers(accountIds.secondary)).toHaveLength(1)
      expect(getMembers(accountIds.primary)[0].npub).toBe(nostrKeys.alice.npub)
      expect(getMembers(accountIds.secondary)[0].npub).toBe(nostrKeys.bob.npub)
    })
  })

  describe('EOSE timestamps', () => {
    it('sets and gets protocol EOSE timestamp', () => {
      const { setLastProtocolEOSE, getLastProtocolEOSE } =
        useNostrStore.getState()

      setLastProtocolEOSE(accountIds.primary, timestamps.recent)

      expect(getLastProtocolEOSE(accountIds.primary)).toBe(timestamps.recent)
    })

    it('returns undefined for unknown account', () => {
      const { getLastProtocolEOSE } = useNostrStore.getState()

      expect(getLastProtocolEOSE(accountIds.nonexistent)).toBeUndefined()
    })
  })

  describe('clearNostrState', () => {
    it('resets all state for account', async () => {
      const store = useNostrStore.getState()

      // Set up state
      await store.addMember(accountIds.primary, nostrKeys.alice.npub)
      store.addProcessedMessageId(accountIds.primary, 'msg-1')
      store.addProcessedEvent(accountIds.primary, 'evt-1')

      // Clear state
      store.clearNostrState(accountIds.primary)

      // Verify all cleared
      expect(store.getMembers(accountIds.primary)).toEqual([])
      expect(store.getProcessedMessageIds(accountIds.primary)).toEqual([])
      expect(store.getProcessedEvents(accountIds.primary)).toEqual([])
    })

    it('does not affect other accounts', async () => {
      const store = useNostrStore.getState()

      await store.addMember(accountIds.primary, nostrKeys.alice.npub)
      await store.addMember(accountIds.secondary, nostrKeys.bob.npub)

      store.clearNostrState(accountIds.primary)

      expect(store.getMembers(accountIds.primary)).toEqual([])
      expect(store.getMembers(accountIds.secondary)).toHaveLength(1)
    })
  })
})

Testing Utilities

Unit tests for utility functions:
tests/unit/utils/bitcoin.test.ts
import {
  getDerivationPathFromScriptVersion,
  getScriptVersionFromDerivationPath,
  satsToBtc,
  btcToSats
} from '@/utils/bitcoin'

describe('bitcoin utils', () => {
  describe('getDerivationPathFromScriptVersion', () => {
    it('returns BIP44 path for P2PKH', () => {
      expect(getDerivationPathFromScriptVersion('P2PKH', 'bitcoin')).toBe(
        "m/44'/0'/0'"
      )
    })

    it('returns BIP49 path for P2SH-P2WPKH', () => {
      expect(getDerivationPathFromScriptVersion('P2SH-P2WPKH', 'bitcoin')).toBe(
        "m/49'/0'/0'"
      )
    })

    it('returns BIP84 path for P2WPKH', () => {
      expect(getDerivationPathFromScriptVersion('P2WPKH', 'bitcoin')).toBe(
        "m/84'/0'/0'"
      )
    })

    it('returns BIP86 path for P2TR', () => {
      expect(getDerivationPathFromScriptVersion('P2TR', 'bitcoin')).toBe(
        "m/86'/0'/0'"
      )
    })

    it('handles testnet paths', () => {
      expect(getDerivationPathFromScriptVersion('P2WPKH', 'testnet')).toBe(
        "m/84'/1'/0'"
      )
    })
  })

  describe('unit conversion', () => {
    it('converts sats to BTC', () => {
      expect(satsToBtc(100000000)).toBe(1.0)
      expect(satsToBtc(50000000)).toBe(0.5)
      expect(satsToBtc(1)).toBe(0.00000001)
    })

    it('converts BTC to sats', () => {
      expect(btcToSats(1.0)).toBe(100000000)
      expect(btcToSats(0.5)).toBe(50000000)
      expect(btcToSats(0.00000001)).toBe(1)
    })
  })
})

Integration Testing

Testing API Clients

Integration tests verify API client functionality against real endpoints:
tests/int/api/esplora.test.ts
import Esplora from '@/api/esplora'

let esplora: Esplora

beforeAll(async () => {
  esplora = new Esplora('https://mempool.space/api')
})

describe('Esplora tests', () => {
  it('get tx status', async () => {
    const txid =
      '591e91f809d716912ca1d4a9295e70c3e78bab077683f79350f101da64588073'
    const resp = await esplora.getTxStatus(txid)
    expect(resp).toHaveProperty('confirmed')
    expect(resp).toHaveProperty('block_height')
  })

  it('get tx hex', async () => {
    const txid =
      '591e91f809d716912ca1d4a9295e70c3e78bab077683f79350f101da64588073'
    const resp = await esplora.getTxHex(txid)
    expect(typeof resp).toBe('string')
    expect(resp.length).toBeGreaterThan(0)
  })

  it('get address utxos', async () => {
    const address = 'bc1qs308e0rcv8aycdq3jcdxxu60ws3a6a5rcnhfyv'
    const resp = await esplora.getAddressUtxos(address)
    expect(Array.isArray(resp)).toBe(true)
    resp.forEach((utxo) => {
      expect(utxo).toHaveProperty('txid')
      expect(utxo).toHaveProperty('value')
    })
  })

  it('get fee estimates', async () => {
    const resp = await esplora.getFeeEstimates()
    expect(typeof resp).toBe('object')
    expect(resp).toHaveProperty('1')
    expect(typeof resp['1']).toBe('number')
  })

  it('get block info', async () => {
    const blockHash =
      '0000000054487811fc4ff7a95be738aa5ad9320c394c482b27c0da28b227ad5d'
    const resp = await esplora.getBlockInfo(blockHash)
    expect(resp).toHaveProperty('id', blockHash)
    expect(resp).toHaveProperty('height')
  })

  it('get mempool info', async () => {
    const resp = await esplora.getMempoolInfo()
    expect(resp).toHaveProperty('count')
    expect(resp).toHaveProperty('vsize')
    expect(resp).toHaveProperty('total_fee')
    expect(resp).toHaveProperty('fee_histogram')
  })
})

Mocking Dependencies

Mock MMKV Storage

__mocks__/mmkv.ts
const storage: Record<string, string> = {}

export default {
  setItem: jest.fn((name: string, value: string) => {
    storage[name] = value
  }),
  getItem: jest.fn((name: string) => storage[name] ?? null),
  removeItem: jest.fn((name: string) => {
    delete storage[name]
  }),
  clearAll: jest.fn(() => {
    Object.keys(storage).forEach((key) => delete storage[key])
  })
}

Mock API Responses

jest.mock('@/api/esplora', () => ({
  __esModule: true,
  default: jest.fn().mockImplementation(() => ({
    getTxStatus: jest.fn().mockResolvedValue({
      confirmed: true,
      block_height: 800000,
      block_time: 1700000000
    }),
    getAddressUtxos: jest.fn().mockResolvedValue([
      {
        txid: 'abc123',
        vout: 0,
        value: 100000,
        status: { confirmed: true, block_height: 800000 }
      }
    ])
  }))
}))

Mock BDK Wallet

jest.mock('bdk-rn', () => ({
  Wallet: jest.fn().mockImplementation(() => ({
    create: jest.fn().mockResolvedValue({}),
    getBalance: jest.fn().mockResolvedValue({
      confirmed: 100000,
      trustedPending: 0,
      untrustedPending: 0
    }),
    sync: jest.fn().mockResolvedValue(undefined),
    listTransactions: jest.fn().mockResolvedValue([]),
    listUnspent: jest.fn().mockResolvedValue([])
  })),
  Descriptor: jest.fn().mockImplementation(() => ({
    create: jest.fn().mockResolvedValue({}),
    asString: jest.fn().mockResolvedValue('wpkh([fingerprint/84h/0h/0h]xpub...)')
  }))
}))

Test Data Fixtures

Create reusable test fixtures:
tests/fixtures/accounts.ts
export const mockAccount = {
  id: 'account-123',
  name: 'Test Account',
  policyType: 'singlesig' as const,
  network: 'bitcoin' as const,
  keys: [
    {
      index: 0,
      name: 'Key 1',
      creationType: 'generateMnemonic' as const,
      scriptVersion: 'P2WPKH' as const,
      fingerprint: 'abcd1234',
      mnemonicWordCount: 24
    }
  ],
  keysRequired: 1,
  addresses: [],
  transactions: [],
  utxos: [],
  labels: {},
  summary: {
    balance: 0,
    numberOfAddresses: 0,
    numberOfTransactions: 0,
    numberOfUtxos: 0,
    satsInMempool: 0
  },
  createdAt: new Date('2024-01-01'),
  syncStatus: 'synced' as const
}

export const mockUtxo = {
  txid: '591e91f809d716912ca1d4a9295e70c3e78bab077683f79350f101da64588073',
  vout: 0,
  value: 100000,
  addressTo: 'bc1qtest...',
  keychain: 'external' as const,
  timestamp: new Date('2024-01-01'),
  label: 'Test UTXO',
  script: [0x00, 0x14]
}

Testing Best Practices

  1. Isolate Tests - Each test should be independent and not rely on others
  2. Mock External Dependencies - Mock API calls, storage, and third-party libraries
  3. Use Descriptive Names - Test names should clearly describe what they test
  4. Test Edge Cases - Include tests for error conditions and edge cases
  5. Keep Tests Fast - Unit tests should run quickly; save slow tests for integration
  6. Coverage Goals - Aim for high coverage but focus on critical paths
  7. Test Behavior, Not Implementation - Test what the code does, not how it does it
  8. Use Fixtures - Create reusable test data to keep tests DRY
  9. Clean Up - Reset state in beforeEach or afterEach hooks
  10. Integration Tests Separately - Keep integration tests in separate directory

Coverage Reports

Generate coverage reports:
yarn test --coverage
View coverage in:
  • Terminal output
  • coverage/lcov-report/index.html for HTML report
Target coverage goals:
  • Stores: >80% coverage
  • Utilities: >90% coverage
  • API Clients: >70% coverage (integration tests)
  • Components: >60% coverage (with Storybook)

Continuous Integration

Run tests in CI/CD pipeline:
.github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '22'
      - run: yarn install
      - run: yarn test:unit
      - run: yarn test:int
      - run: yarn test --coverage

Debugging Tests

Debug failing tests:
# Run single test file
yarn test tests/unit/store/nostr.test.ts

# Run tests matching pattern
yarn test --testNamePattern="adds member"

# Run in debug mode
node --inspect-brk node_modules/.bin/jest --runInBand

Resources