내가 데스크톱 회계 앱에 475개의 테스트를 작성한 이유

발행: (2026년 5월 24일 PM 05:46 GMT+9)
8 분 소요
원문: Dev.to

출처: Dev.to

재무 소프트웨어 테스트의 도전

장부가 틀리면 앱이 충돌하지 않는다. 그냥 거짓말을 한다.
대부분의 버그는 소리를 낸다—충돌, 깨진 UI, 정렬이 어긋난 텍스트. 재무 버그는 속삭인다:

  • 판매가 UI에서 사라진다.
  • 고객 잔액이 여전히 ₹500을 표시한다.
  • 몇 달 뒤 장부가 맞지 않아 감사인이 질문을 하고, 신뢰가 무너진다.

이 때문에 Hisaab Pro, 인도 소규모 사업자를 위한 데스크톱 회계 앱은 475개의 테스트와 함께 배포된다.

스택 개요

레이어기술
런타임Node.js + Express
데이터베이스SQLite (AES‑256 암호화, 회계 연도별 파일 하나)
프론트엔드Vanilla JavaScript — 프레임워크 없음
배포USB 스틱. start.bat를 더블클릭. 설치 필요 없고, 클라우드도, 인터넷도 없음.

실제 시나리오

Ramesh는 자이푸르에서 하드웨어 매장을 운영한다. 그는 USB 드라이브를 PC에 꽂고 버튼을 클릭해 현금 계좌에 있는 ₹50,000이 고객이 실제로 지불한 금액과 일치하기를 기대한다. 그는 SQLite 트랜잭션을 이해하거나 직접 장부를 감사할 필요가 없으며, 단지 실수로 입력한 판매를 삭제했을 때 잔액이 정확히 유지되기만 하면 된다.

원래 버그

// File: server/modules/sales/sales.service.js (original, line ~186)
function deleteSale(id, isDecoy) {
    var stmt = db.prepare('UPDATE sales SET is_deleted = 1, updated_at = datetime(\'now\', \'localtime\') WHERE id = ? AND is_decoy = ?');
    stmt.run(id, isDecoy ? 1 : 0);
    logger.info('Sales', 'Sale deleted: ID ' + id + (isDecoy ? ' [DECOY]' : ''));
    return true;
}

무슨 일이 일어났나요?
판매는 삭제된 것으로 표시됐지만, 고객 계정 잔액과 관련 원장 항목은 롤백되지 않았다. UI에서는 판매가 사라졌지만, 금전 흐름은 남아 “유령” 데이터가 생성된 것이다.

수정 코드

// File: server/modules/sales/sales.service.js (lines 441–487)
function deleteSale(id, isDecoy) {
    var sale = getSaleById(id, isDecoy);
    if (!sale) return false;

    var transaction = db.transaction(function() {
        var deletedInvoiceNo = sale.invoice_no + '-DEL-' + Date.now();
        db.prepare('UPDATE sales SET is_deleted = 1, invoice_no = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
            .run(deletedInvoiceNo, id);

        if (sale.customer_account_id) {
            db.prepare('UPDATE accounts SET current_balance = current_balance - ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
                .run(sale.total, sale.customer_account_id);
        }

        db.prepare('UPDATE transactions SET is_deleted = 1 WHERE linked_sale_id = ?').run(id);

        var payments = db.prepare('SELECT * FROM payments WHERE sale_id = ? AND is_decoy = ? AND is_deleted = 0')
            .all(id, isDecoy ? 1 : 0);
        payments.forEach(function(payment) {
            var assetAccount = payment.mode === 'cash'
                ? accountsService.getDefaultCashAccount(isDecoy)
                : accountsService.getDefaultBankAccount(isDecoy);
            if (assetAccount) {
                var assetRevert = payment.type === 'in' ? -payment.amount : payment.amount;
                db.prepare('UPDATE accounts SET current_balance = current_balance + ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
                    .run(assetRevert, assetAccount.id);
            }

            if (payment.type === 'in' && sale.id) {
                db.prepare('UPDATE sales SET amount_paid = amount_paid - ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
                    .run(payment.amount, sale.id);
            }

            db.prepare('UPDATE payments SET is_deleted = 1 WHERE id = ?').run(payment.id);
        });

        return true;
    });

    transaction();
    logger.info('Sales', 'Sale deleted: ID ' + id + (isDecoy ? ' [DECOY]' : ''));
    return true;
}

이제 잔액이 올바르게 롤백되고 원장에 삭제된 판매 흔적이 남지 않는다.

레트로액티브 마이그레이션

UPDATE transactions SET is_deleted = 1
WHERE linked_sale_id IN (SELECT id FROM sales WHERE is_deleted = 1);

단일 WHERE 절을 프로덕션 데이터베이스에 실행하면 남아 있던 유령 레코드가 모두 사라진다.

유령 잔액을 잡아낸 테스트

// File: tests/double-entry.test.js (lines 830–887)
const saleResponse = await request(app)
    .post('/api/v1/sales')
    .set('Cookie', cookies)
    .send({ customer_account_id: testCustomerId, total: 1000, amount_paid: 1000, date: '2026-04-29' });
expect(saleResponse.status).toBe(201);

const db = new Database(TEST_DB_PATH);
db.pragma(`key = '${TEST_DB_KEY}'`);
const countBefore = db.prepare(
    'SELECT COUNT(*) as count FROM transactions WHERE is_deleted = 0'
).get().count;
db.close();

// Act — try to create a sale with a NEGATIVE amount
const invalidSaleData = {
    customer_account_id: testCustomerId,
    total: -500,
    amount_paid: -500,
    date: '2026-04-29'
};

const failResponse = await request(app)
    .post('/api/v1/sales')
    .set('Cookie', cookies)
    .send(invalidSaleData);
expect(failResponse.status).toBe(400);

// Assert — verify the transaction count is EXACTLY the same
const db2 = new Database(TEST_DB_PATH);
db2.pragma(`key = '${TEST_DB_KEY}'`);
const countAfter = db2.prepare(
    'SELECT COUNT(*) as count FROM transactions WHERE is_deleted = 0'
).get().count;
db2.close();

expect(countAfter).toBe(countBefore);

왜 중요한가
테스트는 실패 요청 후 원시 트랜잭션 수를 확인한다. API는 400을 반환했지만, 이 테스트가 없었다면 부분 삽입이 남아 고아 레코드가 생성돼 장부가 손상됐을 것이다.

쓰기 중단 시 크래시 시뮬레이션

// File: tests/crash-simulation.test.js (lines 228–258)
test('partial writes should not persist after crash', () => {
    let writeCount = 0;
    const mockStmt = {
        run: jest.fn().mockImplementation(() => {
            writeCount++;
            if (writeCount === 2) {
                throw new Error('SIGKILL: Process terminated during second write');
            }
            return { changes: 1, lastInsertRowid: writeCount };
        })
    };
    mockDb.prepare.mockReturnValue(mockStmt);

    const transactionFn = mockDb.transaction(function() {
        mockDb.prepare('INSERT INTO sales (total) VALUES (?)').run(1000);
        mockDb.prepare('INSERT INTO transactions (amount) VALUES (?)').run(1000);
    });

    expect(() => { transactionFn(); }).toThrow('SIGKILL: Process terminated during second write');
    expect(writeCount).toBe(2);
});

모든 연결은 이제 PRAGMA journal_mode = WAL을 강제하고, 모든 다단계 작업을 db.transaction()으로 감싼다. 프로세스가 쓰기 도중 죽어도 데이터베이스는 변경되지 않는다.

잡힌 다른 눈에 보이지 않는 버그들

버그설명
청구서 번호 재사용INV-05를 삭제하면 번호가 해제돼 다음 판매가 동일 번호를 사용해 감사 추적이 깨졌다. 삭제된 청구서는 INV-05-DEL-{timestamp}로 이름을 바꾸어 해결.
조용한 500 오류절반 정도의 API 엔드포인트가 오류 시 {}만 반환해 SQL 구문이 노출되는 경우가 있었다.
하드코딩된 암호키database_key가 없을 경우 공개 기본 키가 사용돼 모든 데이터베이스가 노출됐다.
중복 급여 지급generatePayroll()를 같은 직원·월에 두 번 호출하면 급여가 두 번 지급됐다.
계정 잔액 드리프트판매 금액을 수정해도 고객 current_balance가 재동기화되지 않았다.

이 버그들은 UI에서는 보이지 않았지만, 테스트를 통해 사용자에게 보여지기 전에 잡혔다.

테스트 스위트 구성

레이어테스트 대략 수검증 내용
회계 무결성~130복식부기(차변 = 대변), 원장 잔액, 시산표 = 0
데이터 안전~110WAL 모드, 원자적 롤백, 크래시 복구, 백업/복원
비즈니스 co
0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.