내가 데스크톱 회계 앱에 475개의 테스트를 작성한 이유
출처: 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 |
| 데이터 안전 | ~110 | WAL 모드, 원자적 롤백, 크래시 복구, 백업/복원 |
| 비즈니스 co |