Prerequisites
- Understanding of TypeScript classes and object-oriented programming π
- Knowledge of unit testing fundamentals and Jest β‘
- Familiarity with dependency injection and design patterns π»
What you'll learn
- Test TypeScript classes with comprehensive coverage of methods and properties π―
- Master testing patterns for inheritance, composition, and polymorphism ποΈ
- Handle dependency injection and mocking in object-oriented architectures π
- Create maintainable test suites for complex class hierarchies β¨
π― Introduction
Welcome to the architectural workshop of TypeScript class testing! ποΈ If testing regular functions were like inspecting individual building materials, then testing classes would be like evaluating entire architectural structures - complete with foundations (constructors), load-bearing walls (methods), electrical systems (properties), and complex interconnections (inheritance and composition) that all need to work together harmoniously to create a stable, functional building!
Object-oriented programming introduces unique testing challenges: state management, method interactions, inheritance hierarchies, encapsulation boundaries, and complex dependencies between objects. In TypeScript, we have the additional power of interfaces, access modifiers, and strong typing to help us create robust class-based architectures.
By the end of this tutorial, youβll be a master architect of class testing, capable of thoroughly testing everything from simple data classes to complex enterprise-level object hierarchies. Youβll learn to test state mutations, method interactions, inheritance patterns, and dependency relationships while maintaining clean, maintainable test code. Letβs build some bulletproof class tests! π
π Understanding Class Testing Fundamentals
π€ Why Class Testing Is Special
Classes encapsulate state and behavior, creating unique testing challenges around state mutations, method interactions, and object lifecycle management.
// π Setting up comprehensive class testing environment
// Basic class example with various testing scenarios
class BankAccount {
private _balance: number;
private _transactions: Transaction[];
private _accountNumber: string;
private _isLocked: boolean = false;
private _dailyWithdrawalLimit: number = 1000;
private _dailyWithdrawals: number = 0;
private _lastTransactionDate: Date;
constructor(
initialBalance: number = 0,
accountNumber?: string
) {
if (initialBalance < 0) {
throw new Error('Initial balance cannot be negative');
}
this._balance = initialBalance;
this._accountNumber = accountNumber || this.generateAccountNumber();
this._transactions = [];
this._lastTransactionDate = new Date();
}
// Getter methods
get balance(): number {
return this._balance;
}
get accountNumber(): string {
return this._accountNumber;
}
get isLocked(): boolean {
return this._isLocked;
}
get transactionHistory(): readonly Transaction[] {
return [...this._transactions];
}
get dailyWithdrawalsRemaining(): number {
return Math.max(0, this._dailyWithdrawalLimit - this._dailyWithdrawals);
}
// State-changing methods
deposit(amount: number, description: string = 'Deposit'): void {
this.validateAmount(amount);
this.checkAccountLocked();
const oldBalance = this._balance;
this._balance += amount;
this.recordTransaction({
type: 'deposit',
amount,
description,
balanceBefore: oldBalance,
balanceAfter: this._balance,
timestamp: new Date()
});
}
withdraw(amount: number, description: string = 'Withdrawal'): void {
this.validateAmount(amount);
this.checkAccountLocked();
this.checkSufficientFunds(amount);
this.checkDailyLimit(amount);
const oldBalance = this._balance;
this._balance -= amount;
this._dailyWithdrawals += amount;
this.recordTransaction({
type: 'withdrawal',
amount,
description,
balanceBefore: oldBalance,
balanceAfter: this._balance,
timestamp: new Date()
});
}
transfer(amount: number, targetAccount: BankAccount, description: string = 'Transfer'): void {
this.withdraw(amount, `${description} to ${targetAccount.accountNumber}`);
targetAccount.deposit(amount, `${description} from ${this.accountNumber}`);
}
// Account management methods
lockAccount(): void {
this._isLocked = true;
}
unlockAccount(): void {
this._isLocked = false;
}
setDailyWithdrawalLimit(limit: number): void {
if (limit < 0) {
throw new Error('Daily withdrawal limit cannot be negative');
}
this._dailyWithdrawalLimit = limit;
}
resetDailyWithdrawals(): void {
this._dailyWithdrawals = 0;
}
// Query methods
getTransactionsByType(type: TransactionType): Transaction[] {
return this._transactions.filter(t => t.type === type);
}
getTransactionsByDateRange(start: Date, end: Date): Transaction[] {
return this._transactions.filter(t =>
t.timestamp >= start && t.timestamp <= end
);
}
calculateInterest(rate: number, days: number): number {
return (this._balance * rate * days) / 365;
}
// Private helper methods
private validateAmount(amount: number): void {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
if (!Number.isFinite(amount)) {
throw new Error('Amount must be a valid number');
}
}
private checkAccountLocked(): void {
if (this._isLocked) {
throw new Error('Account is locked');
}
}
private checkSufficientFunds(amount: number): void {
if (amount > this._balance) {
throw new Error('Insufficient funds');
}
}
private checkDailyLimit(amount: number): void {
if (this._dailyWithdrawals + amount > this._dailyWithdrawalLimit) {
throw new Error('Daily withdrawal limit exceeded');
}
}
private recordTransaction(transaction: Omit<Transaction, 'id'>): void {
const fullTransaction: Transaction = {
id: this.generateTransactionId(),
...transaction
};
this._transactions.push(fullTransaction);
this._lastTransactionDate = transaction.timestamp;
}
private generateAccountNumber(): string {
return Math.random().toString(36).substr(2, 10).toUpperCase();
}
private generateTransactionId(): string {
return `TXN-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
}
}
// Supporting types and interfaces
type TransactionType = 'deposit' | 'withdrawal';
interface Transaction {
id: string;
type: TransactionType;
amount: number;
description: string;
balanceBefore: number;
balanceAfter: number;
timestamp: Date;
}
// More complex class with dependencies
interface Logger {
log(message: string, level?: 'info' | 'warn' | 'error'): void;
getLastLogMessage(): string | null;
}
interface NotificationService {
sendNotification(accountNumber: string, message: string): Promise<void>;
getNotificationHistory(accountNumber: string): string[];
}
interface AuditService {
recordEvent(event: AuditEvent): void;
getAuditTrail(accountNumber: string): AuditEvent[];
}
interface AuditEvent {
accountNumber: string;
action: string;
timestamp: Date;
details: any;
}
class PremiumBankAccount extends BankAccount {
private _overdraftLimit: number;
private _interestRate: number;
private _premiumFeatures: Set<string>;
constructor(
initialBalance: number,
accountNumber: string,
private logger: Logger,
private notificationService: NotificationService,
private auditService: AuditService,
overdraftLimit: number = 500,
interestRate: number = 0.02
) {
super(initialBalance, accountNumber);
this._overdraftLimit = overdraftLimit;
this._interestRate = interestRate;
this._premiumFeatures = new Set(['overdraft', 'priority_support', 'monthly_statements']);
this.logger.log(`Premium account created: ${accountNumber}`, 'info');
this.auditService.recordEvent({
accountNumber,
action: 'account_created',
timestamp: new Date(),
details: { initialBalance, overdraftLimit, interestRate }
});
}
// Override withdrawal with overdraft support
withdraw(amount: number, description: string = 'Withdrawal'): void {
this.validateAmount(amount);
this.checkAccountLocked();
this.checkOverdraftLimit(amount);
const oldBalance = this.balance;
const availableFunds = this.balance + this._overdraftLimit;
if (amount > availableFunds) {
throw new Error('Exceeds available funds including overdraft');
}
// Use parent's private method indirectly by accessing balance
const newBalance = this.balance - amount;
// Record transaction and audit
this.auditService.recordEvent({
accountNumber: this.accountNumber,
action: 'withdrawal',
timestamp: new Date(),
details: { amount, description, oldBalance, newBalance }
});
// Call parent's deposit/withdraw to update balance
// This is a workaround since we can't access private methods directly
if (newBalance >= 0) {
super.withdraw(amount, description);
} else {
// Handle overdraft scenario
this.handleOverdraftWithdrawal(amount, description, oldBalance);
}
// Send notification for large withdrawals
if (amount > 1000) {
this.notificationService.sendNotification(
this.accountNumber,
`Large withdrawal of ${amount} processed`
).catch(error => {
this.logger.log(`Failed to send notification: ${error.message}`, 'error');
});
}
}
// Premium-specific methods
applyMonthlyInterest(): number {
if (this.balance <= 0) return 0;
const interest = this.calculateInterest(this._interestRate, 30);
this.deposit(interest, 'Monthly interest');
this.logger.log(`Interest applied: ${interest}`, 'info');
this.auditService.recordEvent({
accountNumber: this.accountNumber,
action: 'interest_applied',
timestamp: new Date(),
details: { amount: interest, rate: this._interestRate }
});
return interest;
}
upgradeFeature(feature: string): void {
if (this._premiumFeatures.has(feature)) {
throw new Error(`Feature ${feature} already enabled`);
}
this._premiumFeatures.add(feature);
this.logger.log(`Feature enabled: ${feature}`, 'info');
this.auditService.recordEvent({
accountNumber: this.accountNumber,
action: 'feature_upgraded',
timestamp: new Date(),
details: { feature }
});
}
hasFeature(feature: string): boolean {
return this._premiumFeatures.has(feature);
}
getAvailableBalance(): number {
return this.balance + this._overdraftLimit;
}
getOverdraftUsed(): number {
return Math.max(0, -this.balance);
}
// Getters
get overdraftLimit(): number {
return this._overdraftLimit;
}
get interestRate(): number {
return this._interestRate;
}
get premiumFeatures(): readonly string[] {
return Array.from(this._premiumFeatures);
}
// Private methods
private checkOverdraftLimit(amount: number): void {
const wouldExceedOverdraft = (amount - this.balance) > this._overdraftLimit;
if (wouldExceedOverdraft) {
throw new Error('Would exceed overdraft limit');
}
}
private handleOverdraftWithdrawal(amount: number, description: string, oldBalance: number): void {
// Complex overdraft handling logic
const overdraftUsed = amount - oldBalance;
this.logger.log(
`Overdraft used: ${overdraftUsed} for withdrawal of ${amount}`,
'warn'
);
// This would need to access parent's protected/private methods
// In a real implementation, we'd need proper access modifiers
}
}
// Abstract base class for testing inheritance
abstract class Account {
protected _accountId: string;
protected _owner: string;
protected _createdAt: Date;
constructor(accountId: string, owner: string) {
this._accountId = accountId;
this._owner = owner;
this._createdAt = new Date();
}
get accountId(): string {
return this._accountId;
}
get owner(): string {
return this._owner;
}
get createdAt(): Date {
return this._createdAt;
}
abstract getBalance(): number;
abstract deposit(amount: number): void;
abstract withdraw(amount: number): void;
protected validateOwner(owner: string): void {
if (!owner || owner.trim().length === 0) {
throw new Error('Owner name is required');
}
}
getAccountInfo(): string {
return `Account ${this._accountId} owned by ${this._owner}`;
}
}
// Concrete implementation for testing
class SavingsAccount extends Account {
private _balance: number = 0;
private _interestRate: number;
constructor(accountId: string, owner: string, interestRate: number = 0.01) {
super(accountId, owner);
this.validateOwner(owner);
this._interestRate = interestRate;
}
getBalance(): number {
return this._balance;
}
deposit(amount: number): void {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this._balance += amount;
}
withdraw(amount: number): void {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (amount > this._balance) {
throw new Error('Insufficient funds');
}
this._balance -= amount;
}
calculateInterest(): number {
return this._balance * this._interestRate;
}
applyInterest(): void {
const interest = this.calculateInterest();
this.deposit(interest);
}
}
π§ͺ Testing Basic Class Functionality
β Constructor and Property Testing
Testing how classes are initialized and how their properties behave is foundational to class testing.
// π Comprehensive class constructor and property testing
describe('BankAccount - Constructor and Properties', () => {
// β
Testing valid constructor scenarios
describe('Constructor', () => {
it('should create account with default initial balance', () => {
// Act
const account = new BankAccount();
// Assert
expect(account.balance).toBe(0);
expect(account.accountNumber).toBeDefined();
expect(account.accountNumber).toMatch(/^[A-Z0-9]{10}$/);
expect(account.isLocked).toBe(false);
expect(account.transactionHistory).toEqual([]);
});
it('should create account with specified initial balance', () => {
// Arrange
const initialBalance = 1000;
// Act
const account = new BankAccount(initialBalance);
// Assert
expect(account.balance).toBe(initialBalance);
expect(account.transactionHistory).toEqual([]);
});
it('should create account with custom account number', () => {
// Arrange
const accountNumber = 'CUSTOM123';
const initialBalance = 500;
// Act
const account = new BankAccount(initialBalance, accountNumber);
// Assert
expect(account.accountNumber).toBe(accountNumber);
expect(account.balance).toBe(initialBalance);
});
it('should throw error for negative initial balance', () => {
// Act & Assert
expect(() => new BankAccount(-100))
.toThrow('Initial balance cannot be negative');
});
it('should generate unique account numbers', () => {
// Act
const account1 = new BankAccount();
const account2 = new BankAccount();
const account3 = new BankAccount();
// Assert
expect(account1.accountNumber).not.toBe(account2.accountNumber);
expect(account2.accountNumber).not.toBe(account3.accountNumber);
expect(account1.accountNumber).not.toBe(account3.accountNumber);
});
});
// β
Testing property getters
describe('Property Getters', () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(1000, 'TEST123');
});
it('should return correct balance', () => {
expect(account.balance).toBe(1000);
});
it('should return correct account number', () => {
expect(account.accountNumber).toBe('TEST123');
});
it('should return correct locked status', () => {
expect(account.isLocked).toBe(false);
account.lockAccount();
expect(account.isLocked).toBe(true);
});
it('should return empty transaction history initially', () => {
expect(account.transactionHistory).toEqual([]);
expect(account.transactionHistory).toHaveLength(0);
});
it('should return correct daily withdrawals remaining', () => {
expect(account.dailyWithdrawalsRemaining).toBe(1000);
account.withdraw(200);
expect(account.dailyWithdrawalsRemaining).toBe(800);
});
it('should return immutable transaction history', () => {
// Act
const history = account.transactionHistory;
// Try to modify the returned array
expect(() => {
(history as any).push({ fake: 'transaction' });
}).toThrow();
});
});
// β
Testing property state changes
describe('Property State Changes', () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(1000);
});
it('should update balance after deposit', () => {
// Arrange
const initialBalance = account.balance;
const depositAmount = 500;
// Act
account.deposit(depositAmount);
// Assert
expect(account.balance).toBe(initialBalance + depositAmount);
});
it('should update balance after withdrawal', () => {
// Arrange
const initialBalance = account.balance;
const withdrawalAmount = 300;
// Act
account.withdraw(withdrawalAmount);
// Assert
expect(account.balance).toBe(initialBalance - withdrawalAmount);
});
it('should update locked status', () => {
// Initially unlocked
expect(account.isLocked).toBe(false);
// Lock account
account.lockAccount();
expect(account.isLocked).toBe(true);
// Unlock account
account.unlockAccount();
expect(account.isLocked).toBe(false);
});
it('should update transaction history after operations', () => {
// Initially empty
expect(account.transactionHistory).toHaveLength(0);
// After deposit
account.deposit(500, 'Test deposit');
expect(account.transactionHistory).toHaveLength(1);
expect(account.transactionHistory[0].type).toBe('deposit');
expect(account.transactionHistory[0].amount).toBe(500);
// After withdrawal
account.withdraw(200, 'Test withdrawal');
expect(account.transactionHistory).toHaveLength(2);
expect(account.transactionHistory[1].type).toBe('withdrawal');
expect(account.transactionHistory[1].amount).toBe(200);
});
});
});
// Testing complex property interactions
describe('BankAccount - Property Interactions', () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(1000);
});
it('should maintain consistent state across multiple operations', () => {
// Track state throughout operations
const operations = [
{ type: 'deposit', amount: 500 },
{ type: 'withdraw', amount: 200 },
{ type: 'deposit', amount: 300 },
{ type: 'withdraw', amount: 100 }
];
let expectedBalance = 1000;
operations.forEach(op => {
if (op.type === 'deposit') {
account.deposit(op.amount);
expectedBalance += op.amount;
} else {
account.withdraw(op.amount);
expectedBalance -= op.amount;
}
// Verify state consistency after each operation
expect(account.balance).toBe(expectedBalance);
expect(account.transactionHistory).toHaveLength(
operations.indexOf(op) + 1
);
});
// Final state verification
expect(account.balance).toBe(1500);
expect(account.transactionHistory).toHaveLength(4);
});
it('should handle daily withdrawal limit correctly', () => {
// Set a custom daily limit
account.setDailyWithdrawalLimit(600);
expect(account.dailyWithdrawalsRemaining).toBe(600);
// First withdrawal
account.withdraw(300);
expect(account.dailyWithdrawalsRemaining).toBe(300);
// Second withdrawal
account.withdraw(200);
expect(account.dailyWithdrawalsRemaining).toBe(100);
// Should reject withdrawal that exceeds daily limit
expect(() => account.withdraw(200))
.toThrow('Daily withdrawal limit exceeded');
// Reset daily withdrawals
account.resetDailyWithdrawals();
expect(account.dailyWithdrawalsRemaining).toBe(600);
});
it('should maintain transaction history integrity', () => {
// Perform various operations
account.deposit(500, 'Salary');
account.withdraw(200, 'Groceries');
account.deposit(100, 'Refund');
const history = account.transactionHistory;
// Verify transaction details
expect(history[0]).toMatchObject({
type: 'deposit',
amount: 500,
description: 'Salary',
balanceBefore: 1000,
balanceAfter: 1500
});
expect(history[1]).toMatchObject({
type: 'withdrawal',
amount: 200,
description: 'Groceries',
balanceBefore: 1500,
balanceAfter: 1300
});
expect(history[2]).toMatchObject({
type: 'deposit',
amount: 100,
description: 'Refund',
balanceBefore: 1300,
balanceAfter: 1400
});
// Verify all transactions have required fields
history.forEach(transaction => {
expect(transaction.id).toBeDefined();
expect(transaction.timestamp).toBeInstanceOf(Date);
expect(transaction.type).toMatch(/^(deposit|withdrawal)$/);
expect(transaction.amount).toBeGreaterThan(0);
});
});
});
π Testing Method Interactions and State Changes
π― Comprehensive Method Testing
Methods are the behavior of classes, and testing them requires verifying both their direct effects and side effects.
// π Comprehensive method testing patterns
describe('BankAccount - Method Testing', () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(1000, 'TEST123');
});
// β
Testing deposit method
describe('deposit method', () => {
it('should increase balance by deposit amount', () => {
// Arrange
const initialBalance = account.balance;
const depositAmount = 500;
// Act
account.deposit(depositAmount);
// Assert
expect(account.balance).toBe(initialBalance + depositAmount);
});
it('should add transaction to history', () => {
// Arrange
const depositAmount = 250;
const description = 'Test deposit';
// Act
account.deposit(depositAmount, description);
// Assert
const transactions = account.transactionHistory;
expect(transactions).toHaveLength(1);
expect(transactions[0]).toMatchObject({
type: 'deposit',
amount: depositAmount,
description,
balanceBefore: 1000,
balanceAfter: 1250
});
expect(transactions[0].id).toBeDefined();
expect(transactions[0].timestamp).toBeInstanceOf(Date);
});
it('should use default description when not provided', () => {
// Act
account.deposit(100);
// Assert
const transaction = account.transactionHistory[0];
expect(transaction.description).toBe('Deposit');
});
it('should reject zero amount', () => {
expect(() => account.deposit(0))
.toThrow('Amount must be positive');
});
it('should reject negative amount', () => {
expect(() => account.deposit(-100))
.toThrow('Amount must be positive');
});
it('should reject invalid amounts', () => {
expect(() => account.deposit(NaN))
.toThrow('Amount must be a valid number');
expect(() => account.deposit(Infinity))
.toThrow('Amount must be a valid number');
});
it('should reject deposits when account is locked', () => {
// Arrange
account.lockAccount();
// Act & Assert
expect(() => account.deposit(100))
.toThrow('Account is locked');
// Verify balance unchanged
expect(account.balance).toBe(1000);
});
it('should handle multiple consecutive deposits', () => {
// Arrange
const deposits = [100, 200, 300, 150];
let expectedBalance = 1000;
// Act & Assert
deposits.forEach((amount, index) => {
account.deposit(amount);
expectedBalance += amount;
expect(account.balance).toBe(expectedBalance);
expect(account.transactionHistory).toHaveLength(index + 1);
});
});
});
// β
Testing withdrawal method
describe('withdraw method', () => {
it('should decrease balance by withdrawal amount', () => {
// Arrange
const initialBalance = account.balance;
const withdrawalAmount = 300;
// Act
account.withdraw(withdrawalAmount);
// Assert
expect(account.balance).toBe(initialBalance - withdrawalAmount);
});
it('should add transaction to history', () => {
// Arrange
const withdrawalAmount = 200;
const description = 'ATM withdrawal';
// Act
account.withdraw(withdrawalAmount, description);
// Assert
const transactions = account.transactionHistory;
expect(transactions).toHaveLength(1);
expect(transactions[0]).toMatchObject({
type: 'withdrawal',
amount: withdrawalAmount,
description,
balanceBefore: 1000,
balanceAfter: 800
});
});
it('should track daily withdrawal amounts', () => {
// Act
account.withdraw(300);
expect(account.dailyWithdrawalsRemaining).toBe(700);
account.withdraw(200);
expect(account.dailyWithdrawalsRemaining).toBe(500);
});
it('should reject withdrawal exceeding balance', () => {
expect(() => account.withdraw(1500))
.toThrow('Insufficient funds');
// Verify no state change
expect(account.balance).toBe(1000);
expect(account.transactionHistory).toHaveLength(0);
});
it('should reject withdrawal exceeding daily limit', () => {
// First withdrawal should succeed
account.withdraw(800);
expect(account.balance).toBe(200);
// Second withdrawal should fail due to daily limit
expect(() => account.withdraw(300))
.toThrow('Daily withdrawal limit exceeded');
// Balance should remain unchanged from failed withdrawal
expect(account.balance).toBe(200);
});
it('should allow withdrawal up to exact daily limit', () => {
// Withdraw exactly the daily limit
account.withdraw(1000);
expect(account.balance).toBe(0);
expect(account.dailyWithdrawalsRemaining).toBe(0);
});
it('should reject withdrawals when account is locked', () => {
// Arrange
account.lockAccount();
// Act & Assert
expect(() => account.withdraw(100))
.toThrow('Account is locked');
});
});
// β
Testing transfer method
describe('transfer method', () => {
let targetAccount: BankAccount;
beforeEach(() => {
targetAccount = new BankAccount(500, 'TARGET456');
});
it('should transfer money between accounts', () => {
// Arrange
const transferAmount = 300;
const sourceInitialBalance = account.balance;
const targetInitialBalance = targetAccount.balance;
// Act
account.transfer(transferAmount, targetAccount, 'Test transfer');
// Assert
expect(account.balance).toBe(sourceInitialBalance - transferAmount);
expect(targetAccount.balance).toBe(targetInitialBalance + transferAmount);
});
it('should create transactions in both accounts', () => {
// Act
account.transfer(200, targetAccount, 'Transfer test');
// Assert source account
const sourceTransactions = account.transactionHistory;
expect(sourceTransactions).toHaveLength(1);
expect(sourceTransactions[0]).toMatchObject({
type: 'withdrawal',
amount: 200,
description: 'Transfer test to TARGET456'
});
// Assert target account
const targetTransactions = targetAccount.transactionHistory;
expect(targetTransactions).toHaveLength(1);
expect(targetTransactions[0]).toMatchObject({
type: 'deposit',
amount: 200,
description: 'Transfer test from TEST123'
});
});
it('should fail if source account has insufficient funds', () => {
// Act & Assert
expect(() => account.transfer(1500, targetAccount))
.toThrow('Insufficient funds');
// Verify no state changes
expect(account.balance).toBe(1000);
expect(targetAccount.balance).toBe(500);
expect(account.transactionHistory).toHaveLength(0);
expect(targetAccount.transactionHistory).toHaveLength(0);
});
it('should fail if transfer would exceed daily limit', () => {
// Arrange - make a withdrawal first to approach daily limit
account.withdraw(800);
// Act & Assert
expect(() => account.transfer(300, targetAccount))
.toThrow('Daily withdrawal limit exceeded');
});
it('should fail if source account is locked', () => {
// Arrange
account.lockAccount();
// Act & Assert
expect(() => account.transfer(100, targetAccount))
.toThrow('Account is locked');
});
it('should fail if target account is locked', () => {
// Arrange
targetAccount.lockAccount();
// Act & Assert
expect(() => account.transfer(100, targetAccount))
.toThrow('Account is locked');
// Source should be debited but target deposit should fail
// In a real implementation, this would need to be a transaction
});
});
// β
Testing query methods
describe('Query methods', () => {
beforeEach(() => {
// Set up some transaction history
account.deposit(500, 'Salary');
account.withdraw(200, 'Groceries');
account.deposit(100, 'Refund');
account.withdraw(150, 'Gas');
});
it('should filter transactions by type', () => {
// Act
const deposits = account.getTransactionsByType('deposit');
const withdrawals = account.getTransactionsByType('withdrawal');
// Assert
expect(deposits).toHaveLength(2);
expect(withdrawals).toHaveLength(2);
deposits.forEach(tx => expect(tx.type).toBe('deposit'));
withdrawals.forEach(tx => expect(tx.type).toBe('withdrawal'));
});
it('should filter transactions by date range', () => {
// Arrange
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
// Act
const recentTransactions = account.getTransactionsByDateRange(
oneHourAgo,
oneHourFromNow
);
// Assert
expect(recentTransactions).toHaveLength(4); // All transactions should be recent
recentTransactions.forEach(tx => {
expect(tx.timestamp.getTime()).toBeGreaterThanOrEqual(oneHourAgo.getTime());
expect(tx.timestamp.getTime()).toBeLessThanOrEqual(oneHourFromNow.getTime());
});
});
it('should calculate interest correctly', () => {
// Arrange
const rate = 0.05; // 5% annual rate
const days = 365; // One year
// Act
const interest = account.calculateInterest(rate, days);
// Assert
const expectedInterest = account.balance * rate; // For one year
expect(interest).toBeCloseTo(expectedInterest, 2);
});
it('should calculate interest for partial periods', () => {
// Arrange
const rate = 0.12; // 12% annual rate
const days = 30; // One month
// Act
const interest = account.calculateInterest(rate, days);
// Assert
const expectedInterest = (account.balance * rate * days) / 365;
expect(interest).toBeCloseTo(expectedInterest, 2);
});
});
// β
Testing account management methods
describe('Account management', () => {
it('should lock and unlock account', () => {
// Initially unlocked
expect(account.isLocked).toBe(false);
// Lock account
account.lockAccount();
expect(account.isLocked).toBe(true);
// Unlock account
account.unlockAccount();
expect(account.isLocked).toBe(false);
});
it('should set daily withdrawal limit', () => {
// Act
account.setDailyWithdrawalLimit(2000);
// Assert
expect(account.dailyWithdrawalsRemaining).toBe(2000);
});
it('should reject negative daily withdrawal limit', () => {
expect(() => account.setDailyWithdrawalLimit(-100))
.toThrow('Daily withdrawal limit cannot be negative');
});
it('should reset daily withdrawals', () => {
// Arrange - make some withdrawals
account.withdraw(500);
expect(account.dailyWithdrawalsRemaining).toBe(500);
// Act
account.resetDailyWithdrawals();
// Assert
expect(account.dailyWithdrawalsRemaining).toBe(1000);
});
});
});
ποΈ Testing Inheritance and Polymorphism
π Testing Class Hierarchies
Inheritance introduces complexity in testing, requiring verification of both parent and child class behavior.
// π Comprehensive inheritance testing patterns
describe('Abstract Account - Base Class Testing', () => {
// Since Account is abstract, we test through concrete implementation
describe('SavingsAccount inheritance', () => {
let account: SavingsAccount;
beforeEach(() => {
account = new SavingsAccount('SAV123', 'John Doe', 0.02);
});
// β
Testing inherited properties
it('should inherit base class properties', () => {
expect(account.accountId).toBe('SAV123');
expect(account.owner).toBe('John Doe');
expect(account.createdAt).toBeInstanceOf(Date);
});
// β
Testing abstract method implementation
it('should implement abstract methods', () => {
// Test getBalance implementation
expect(account.getBalance()).toBe(0);
// Test deposit implementation
account.deposit(500);
expect(account.getBalance()).toBe(500);
// Test withdraw implementation
account.withdraw(200);
expect(account.getBalance()).toBe(300);
});
// β
Testing inherited methods
it('should use inherited getAccountInfo method', () => {
const info = account.getAccountInfo();
expect(info).toBe('Account SAV123 owned by John Doe');
});
// β
Testing constructor validation from base class
it('should validate owner through base class', () => {
expect(() => new SavingsAccount('SAV456', '', 0.02))
.toThrow('Owner name is required');
expect(() => new SavingsAccount('SAV789', ' ', 0.02))
.toThrow('Owner name is required');
});
// β
Testing subclass-specific functionality
it('should calculate and apply interest', () => {
// Arrange
account.deposit(1000);
const initialBalance = account.getBalance();
// Act
const expectedInterest = account.calculateInterest();
account.applyInterest();
// Assert
expect(expectedInterest).toBe(1000 * 0.02);
expect(account.getBalance()).toBe(initialBalance + expectedInterest);
});
});
});
describe('PremiumBankAccount - Complex Inheritance Testing', () => {
let mockLogger: jest.Mocked<Logger>;
let mockNotificationService: jest.Mocked<NotificationService>;
let mockAuditService: jest.Mocked<AuditService>;
let premiumAccount: PremiumBankAccount;
beforeEach(() => {
// Create mocks for dependencies
mockLogger = {
log: jest.fn(),
getLastLogMessage: jest.fn()
};
mockNotificationService = {
sendNotification: jest.fn(),
getNotificationHistory: jest.fn()
};
mockAuditService = {
recordEvent: jest.fn(),
getAuditTrail: jest.fn()
};
premiumAccount = new PremiumBankAccount(
1000,
'PREM123',
mockLogger,
mockNotificationService,
mockAuditService,
500, // overdraft limit
0.03 // interest rate
);
});
// β
Testing constructor with dependency injection
describe('Constructor with dependencies', () => {
it('should initialize with dependencies and log creation', () => {
expect(premiumAccount.balance).toBe(1000);
expect(premiumAccount.accountNumber).toBe('PREM123');
expect(premiumAccount.overdraftLimit).toBe(500);
expect(premiumAccount.interestRate).toBe(0.03);
// Verify logging
expect(mockLogger.log).toHaveBeenCalledWith(
'Premium account created: PREM123',
'info'
);
// Verify audit
expect(mockAuditService.recordEvent).toHaveBeenCalledWith({
accountNumber: 'PREM123',
action: 'account_created',
timestamp: expect.any(Date),
details: {
initialBalance: 1000,
overdraftLimit: 500,
interestRate: 0.03
}
});
});
it('should initialize with default values', () => {
const defaultAccount = new PremiumBankAccount(
500,
'DEFAULT',
mockLogger,
mockNotificationService,
mockAuditService
);
expect(defaultAccount.overdraftLimit).toBe(500);
expect(defaultAccount.interestRate).toBe(0.02);
});
});
// β
Testing method overriding
describe('Method overriding', () => {
it('should override withdraw with overdraft support', () => {
// Test normal withdrawal
premiumAccount.withdraw(300);
expect(premiumAccount.balance).toBe(700);
// Test overdraft withdrawal
premiumAccount.withdraw(900); // Should use overdraft
expect(premiumAccount.balance).toBe(-200);
expect(premiumAccount.getOverdraftUsed()).toBe(200);
});
it('should reject withdrawal exceeding overdraft limit', () => {
expect(() => premiumAccount.withdraw(1600))
.toThrow('Exceeds available funds including overdraft');
expect(premiumAccount.balance).toBe(1000);
});
it('should send notifications for large withdrawals', async () => {
// Arrange
mockNotificationService.sendNotification.mockResolvedValue();
// Act
premiumAccount.withdraw(1200, 'Large purchase');
// Assert - wait for async notification
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
'PREM123',
'Large withdrawal of 1200 processed'
);
});
it('should log errors when notification fails', async () => {
// Arrange
const notificationError = new Error('Service unavailable');
mockNotificationService.sendNotification.mockRejectedValue(notificationError);
// Act
premiumAccount.withdraw(1200);
// Assert - wait for async operation
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockLogger.log).toHaveBeenCalledWith(
'Failed to send notification: Service unavailable',
'error'
);
});
it('should record audit events for withdrawals', () => {
// Act
premiumAccount.withdraw(300, 'Test withdrawal');
// Assert
expect(mockAuditService.recordEvent).toHaveBeenCalledWith({
accountNumber: 'PREM123',
action: 'withdrawal',
timestamp: expect.any(Date),
details: {
amount: 300,
description: 'Test withdrawal',
oldBalance: 1000,
newBalance: 700
}
});
});
});
// β
Testing premium-specific features
describe('Premium features', () => {
it('should apply monthly interest', () => {
// Act
const interest = premiumAccount.applyMonthlyInterest();
// Assert
const expectedInterest = premiumAccount.calculateInterest(0.03, 30);
expect(interest).toBeCloseTo(expectedInterest, 2);
expect(premiumAccount.balance).toBeCloseTo(1000 + expectedInterest, 2);
// Verify logging and audit
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Interest applied:'),
'info'
);
expect(mockAuditService.recordEvent).toHaveBeenCalledWith({
accountNumber: 'PREM123',
action: 'interest_applied',
timestamp: expect.any(Date),
details: { amount: interest, rate: 0.03 }
});
});
it('should not apply interest for negative balance', () => {
// Arrange - create overdraft situation
premiumAccount.withdraw(1200);
expect(premiumAccount.balance).toBe(-200);
// Act
const interest = premiumAccount.applyMonthlyInterest();
// Assert
expect(interest).toBe(0);
expect(premiumAccount.balance).toBe(-200); // No change
});
it('should manage premium features', () => {
// Initial features
expect(premiumAccount.hasFeature('overdraft')).toBe(true);
expect(premiumAccount.hasFeature('priority_support')).toBe(true);
expect(premiumAccount.hasFeature('monthly_statements')).toBe(true);
// Add new feature
premiumAccount.upgradeFeature('investment_advice');
expect(premiumAccount.hasFeature('investment_advice')).toBe(true);
// Verify feature list
const features = premiumAccount.premiumFeatures;
expect(features).toContain('overdraft');
expect(features).toContain('investment_advice');
// Verify logging and audit
expect(mockLogger.log).toHaveBeenCalledWith(
'Feature enabled: investment_advice',
'info'
);
expect(mockAuditService.recordEvent).toHaveBeenCalledWith({
accountNumber: 'PREM123',
action: 'feature_upgraded',
timestamp: expect.any(Date),
details: { feature: 'investment_advice' }
});
});
it('should reject duplicate feature upgrades', () => {
expect(() => premiumAccount.upgradeFeature('overdraft'))
.toThrow('Feature overdraft already enabled');
});
it('should calculate available balance including overdraft', () => {
expect(premiumAccount.getAvailableBalance()).toBe(1500); // 1000 + 500 overdraft
premiumAccount.withdraw(800);
expect(premiumAccount.getAvailableBalance()).toBe(700); // 200 + 500 overdraft
});
it('should track overdraft usage', () => {
expect(premiumAccount.getOverdraftUsed()).toBe(0);
premiumAccount.withdraw(1200);
expect(premiumAccount.getOverdraftUsed()).toBe(200);
premiumAccount.deposit(300);
expect(premiumAccount.getOverdraftUsed()).toBe(0); // Back to positive balance
});
});
// β
Testing inherited behavior still works
describe('Inherited behavior', () => {
it('should still support basic account operations', () => {
// Deposit
premiumAccount.deposit(500);
expect(premiumAccount.balance).toBe(1500);
// Basic withdrawal (without overdraft)
premiumAccount.withdraw(200);
expect(premiumAccount.balance).toBe(1300);
// Transfer
const targetAccount = new BankAccount(100);
premiumAccount.transfer(300, targetAccount);
expect(premiumAccount.balance).toBe(1000);
expect(targetAccount.balance).toBe(400);
});
it('should maintain transaction history', () => {
premiumAccount.deposit(200);
premiumAccount.withdraw(100);
const history = premiumAccount.transactionHistory;
expect(history).toHaveLength(2);
expect(history[0].type).toBe('deposit');
expect(history[1].type).toBe('withdrawal');
});
it('should respect account locking', () => {
premiumAccount.lockAccount();
expect(() => premiumAccount.deposit(100))
.toThrow('Account is locked');
expect(() => premiumAccount.withdraw(100))
.toThrow('Account is locked');
});
});
});
π§ Testing Dependency Injection and Mocking
π Advanced Dependency Testing
Complex classes often depend on external services, requiring sophisticated mocking strategies.
// π Comprehensive dependency injection testing
describe('Dependency Injection Testing', () => {
let mockLogger: jest.Mocked<Logger>;
let mockNotificationService: jest.Mocked<NotificationService>;
let mockAuditService: jest.Mocked<AuditService>;
beforeEach(() => {
// Create detailed mocks
mockLogger = {
log: jest.fn(),
getLastLogMessage: jest.fn().mockReturnValue(null)
};
mockNotificationService = {
sendNotification: jest.fn(),
getNotificationHistory: jest.fn().mockReturnValue([])
};
mockAuditService = {
recordEvent: jest.fn(),
getAuditTrail: jest.fn().mockReturnValue([])
};
});
// β
Testing constructor dependency injection
describe('Constructor dependency injection', () => {
it('should inject dependencies correctly', () => {
// Act
const account = new PremiumBankAccount(
1000,
'TEST123',
mockLogger,
mockNotificationService,
mockAuditService
);
// Trigger behavior that uses dependencies
account.deposit(100);
// Assert dependencies were called
expect(mockLogger.log).toHaveBeenCalled();
expect(mockAuditService.recordEvent).toHaveBeenCalled();
});
it('should handle null/undefined dependencies gracefully', () => {
// This would require the class to handle null dependencies
// In TypeScript, we'd typically use optional dependencies or null objects
expect(() => {
new PremiumBankAccount(
1000,
'TEST123',
null as any,
mockNotificationService,
mockAuditService
);
}).toThrow(); // Or handle gracefully depending on implementation
});
});
// β
Testing mock behavior configuration
describe('Mock behavior configuration', () => {
let account: PremiumBankAccount;
beforeEach(() => {
account = new PremiumBankAccount(
1000,
'TEST123',
mockLogger,
mockNotificationService,
mockAuditService
);
});
it('should configure mocks with specific return values', () => {
// Arrange
mockLogger.getLastLogMessage.mockReturnValue('Last log message');
mockNotificationService.getNotificationHistory.mockReturnValue([
'Notification 1',
'Notification 2'
]);
mockAuditService.getAuditTrail.mockReturnValue([
{
accountNumber: 'TEST123',
action: 'test_action',
timestamp: new Date(),
details: {}
}
]);
// Act & Assert
expect(mockLogger.getLastLogMessage()).toBe('Last log message');
expect(mockNotificationService.getNotificationHistory('TEST123')).toEqual([
'Notification 1',
'Notification 2'
]);
expect(mockAuditService.getAuditTrail('TEST123')).toHaveLength(1);
});
it('should test async mock behaviors', async () => {
// Arrange
mockNotificationService.sendNotification.mockResolvedValue();
// Act
account.withdraw(1500); // Should trigger notification
// Wait for async operation
await new Promise(resolve => setTimeout(resolve, 0));
// Assert
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
'TEST123',
'Large withdrawal of 1500 processed'
);
});
it('should test mock failures', async () => {
// Arrange
const notificationError = new Error('Notification service down');
mockNotificationService.sendNotification.mockRejectedValue(notificationError);
// Act
account.withdraw(1500);
// Wait for async operation and error handling
await new Promise(resolve => setTimeout(resolve, 10));
// Assert
expect(mockLogger.log).toHaveBeenCalledWith(
'Failed to send notification: Notification service down',
'error'
);
});
it('should verify mock call parameters', () => {
// Act
account.withdraw(250, 'ATM withdrawal');
// Assert detailed call verification
expect(mockAuditService.recordEvent).toHaveBeenCalledWith({
accountNumber: 'TEST123',
action: 'withdrawal',
timestamp: expect.any(Date),
details: {
amount: 250,
description: 'ATM withdrawal',
oldBalance: 1000,
newBalance: 750
}
});
// Verify call count
expect(mockAuditService.recordEvent).toHaveBeenCalledTimes(2); // Constructor + withdrawal
});
it('should test mock call order', () => {
// Act
account.withdraw(300);
account.deposit(200);
// Assert call order
const auditCalls = mockAuditService.recordEvent.mock.calls;
expect(auditCalls[1][0].action).toBe('withdrawal'); // Second call (after constructor)
expect(auditCalls[2][0].action).toBe('interest_applied'); // If deposit triggers interest
});
});
// β
Testing mock verification patterns
describe('Mock verification patterns', () => {
let account: PremiumBankAccount;
beforeEach(() => {
account = new PremiumBankAccount(
1000,
'TEST123',
mockLogger,
mockNotificationService,
mockAuditService
);
// Clear constructor calls
jest.clearAllMocks();
});
it('should verify no unexpected calls', () => {
// Act - perform operation that shouldn't trigger notifications
account.deposit(100);
// Assert
expect(mockNotificationService.sendNotification).not.toHaveBeenCalled();
});
it('should verify exact number of calls', () => {
// Act
account.withdraw(200);
account.withdraw(300);
account.withdraw(100);
// Assert
expect(mockAuditService.recordEvent).toHaveBeenCalledTimes(3);
expect(mockLogger.log).toHaveBeenCalledTimes(3);
});
it('should verify calls with specific matchers', () => {
// Act
account.upgradeFeature('new_feature');
// Assert with specific matchers
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
expect.objectContaining({
accountNumber: 'TEST123',
action: 'feature_upgraded',
timestamp: expect.any(Date),
details: expect.objectContaining({
feature: 'new_feature'
})
})
);
});
it('should verify partial object matching', () => {
// Act
const interest = account.applyMonthlyInterest();
// Assert with partial matching
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: 'interest_applied',
details: expect.objectContaining({
rate: 0.02 // Default rate
})
})
);
});
it('should verify mock implementation was called', () => {
// Arrange - set up mock implementation
const mockImplementation = jest.fn();
mockLogger.log.mockImplementation(mockImplementation);
// Act
account.deposit(100);
// Assert
expect(mockImplementation).toHaveBeenCalled();
});
});
// β
Testing spy patterns
describe('Spy patterns', () => {
it('should spy on real object methods', () => {
// Create a real logger instance
const realLogger: Logger = {
log: (message: string, level?: string) => {
console.log(`[${level?.toUpperCase() || 'INFO'}] ${message}`);
},
getLastLogMessage: () => 'Real last message'
};
// Spy on the real object
const loggerSpy = jest.spyOn(realLogger, 'log');
const getLastMessageSpy = jest.spyOn(realLogger, 'getLastLogMessage');
// Create account with real logger
const account = new PremiumBankAccount(
1000,
'SPY123',
realLogger,
mockNotificationService,
mockAuditService
);
// Act
account.deposit(200);
// Assert
expect(loggerSpy).toHaveBeenCalled();
expect(getLastMessageSpy).not.toHaveBeenCalled(); // Wasn't called in deposit
// Restore spies
loggerSpy.mockRestore();
getLastMessageSpy.mockRestore();
});
it('should spy on class methods', () => {
const account = new PremiumBankAccount(
1000,
'SPY123',
mockLogger,
mockNotificationService,
mockAuditService
);
// Spy on class methods
const calculateInterestSpy = jest.spyOn(account, 'calculateInterest');
const depositSpy = jest.spyOn(account, 'deposit');
// Act
account.applyMonthlyInterest();
// Assert
expect(calculateInterestSpy).toHaveBeenCalledWith(0.02, 30);
expect(depositSpy).toHaveBeenCalled();
// Restore spies
calculateInterestSpy.mockRestore();
depositSpy.mockRestore();
});
});
// β
Testing mock factories and builders
describe('Mock factories', () => {
// Factory for creating configured mocks
const createMockLogger = (config: {
shouldSucceed?: boolean;
lastMessage?: string;
} = {}): jest.Mocked<Logger> => {
const mock = {
log: jest.fn(),
getLastLogMessage: jest.fn()
};
if (config.shouldSucceed === false) {
mock.log.mockImplementation(() => {
throw new Error('Logger error');
});
}
if (config.lastMessage) {
mock.getLastLogMessage.mockReturnValue(config.lastMessage);
}
return mock;
};
const createMockNotificationService = (config: {
shouldSucceed?: boolean;
history?: string[];
} = {}): jest.Mocked<NotificationService> => {
const mock = {
sendNotification: jest.fn(),
getNotificationHistory: jest.fn()
};
if (config.shouldSucceed === false) {
mock.sendNotification.mockRejectedValue(new Error('Notification failed'));
} else {
mock.sendNotification.mockResolvedValue();
}
mock.getNotificationHistory.mockReturnValue(config.history || []);
return mock;
};
it('should use mock factory for success scenario', () => {
// Arrange
const successLogger = createMockLogger({ shouldSucceed: true });
const successNotifications = createMockNotificationService({ shouldSucceed: true });
const account = new PremiumBankAccount(
1000,
'FACTORY123',
successLogger,
successNotifications,
mockAuditService
);
// Act
account.withdraw(1500);
// Assert
expect(successLogger.log).toHaveBeenCalled();
expect(successNotifications.sendNotification).toHaveBeenCalled();
});
it('should use mock factory for failure scenario', async () => {
// Arrange
const failingLogger = createMockLogger({ shouldSucceed: false });
const failingNotifications = createMockNotificationService({ shouldSucceed: false });
const account = new PremiumBankAccount(
1000,
'FACTORY456',
failingLogger,
failingNotifications,
mockAuditService
);
// Act & Assert
expect(() => account.deposit(100)).toThrow('Logger error');
});
});
});
π§ͺ Testing Private Methods and Encapsulation
π Testing Through Public Interface
Testing private methods requires careful consideration of encapsulation while ensuring comprehensive coverage.
// π Testing private methods and encapsulation
describe('Private Method Testing', () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(1000, 'TEST123');
});
// β
Testing private methods through public interface
describe('Testing private validation methods', () => {
it('should test validateAmount through public methods', () => {
// Test positive amounts work
expect(() => account.deposit(100)).not.toThrow();
expect(() => account.withdraw(100)).not.toThrow();
// Test zero amount validation
expect(() => account.deposit(0))
.toThrow('Amount must be positive');
expect(() => account.withdraw(0))
.toThrow('Amount must be positive');
// Test negative amount validation
expect(() => account.deposit(-50))
.toThrow('Amount must be positive');
expect(() => account.withdraw(-50))
.toThrow('Amount must be positive');
// Test invalid number validation
expect(() => account.deposit(NaN))
.toThrow('Amount must be a valid number');
expect(() => account.withdraw(Infinity))
.toThrow('Amount must be a valid number');
});
it('should test checkAccountLocked through public methods', () => {
// Account starts unlocked - operations should work
expect(() => account.deposit(100)).not.toThrow();
expect(() => account.withdraw(100)).not.toThrow();
// Lock account
account.lockAccount();
// Now operations should fail
expect(() => account.deposit(100))
.toThrow('Account is locked');
expect(() => account.withdraw(100))
.toThrow('Account is locked');
// Unlock and verify operations work again
account.unlockAccount();
expect(() => account.deposit(100)).not.toThrow();
});
it('should test checkSufficientFunds through withdrawal', () => {
// Should work with sufficient funds
expect(() => account.withdraw(500)).not.toThrow();
expect(account.balance).toBe(500);
// Should fail with insufficient funds
expect(() => account.withdraw(600))
.toThrow('Insufficient funds');
// Balance should remain unchanged
expect(account.balance).toBe(500);
});
it('should test checkDailyLimit through multiple withdrawals', () => {
// First withdrawal should work
account.withdraw(800);
expect(account.balance).toBe(200);
// Second withdrawal should work (within daily limit)
account.withdraw(100);
expect(account.balance).toBe(100);
// Third withdrawal should fail (exceeds daily limit)
expect(() => account.withdraw(200))
.toThrow('Daily withdrawal limit exceeded');
});
});
// β
Testing private helper methods through side effects
describe('Testing private helper methods', () => {
it('should test generateAccountNumber uniqueness', () => {
// Create multiple accounts and verify unique account numbers
const accounts = Array.from({ length: 100 }, () => new BankAccount());
const accountNumbers = accounts.map(acc => acc.accountNumber);
const uniqueNumbers = new Set(accountNumbers);
expect(uniqueNumbers.size).toBe(accountNumbers.length);
});
it('should test generateTransactionId uniqueness', () => {
// Perform multiple transactions and verify unique IDs
for (let i = 0; i < 10; i++) {
account.deposit(10, `Deposit ${i}`);
}
const transactions = account.transactionHistory;
const transactionIds = transactions.map(tx => tx.id);
const uniqueIds = new Set(transactionIds);
expect(uniqueIds.size).toBe(transactionIds.length);
// Verify ID format
transactionIds.forEach(id => {
expect(id).toMatch(/^TXN-\d+-[a-z0-9]{6}$/);
});
});
it('should test recordTransaction through transaction history', () => {
// Initial state
expect(account.transactionHistory).toHaveLength(0);
// Perform operations
account.deposit(500, 'Test deposit');
account.withdraw(200, 'Test withdrawal');
// Verify transactions were recorded correctly
const history = account.transactionHistory;
expect(history).toHaveLength(2);
// Verify first transaction
expect(history[0]).toMatchObject({
type: 'deposit',
amount: 500,
description: 'Test deposit',
balanceBefore: 1000,
balanceAfter: 1500
});
// Verify second transaction
expect(history[1]).toMatchObject({
type: 'withdrawal',
amount: 200,
description: 'Test withdrawal',
balanceBefore: 1500,
balanceAfter: 1300
});
// Verify all required fields are present
history.forEach(tx => {
expect(tx.id).toBeDefined();
expect(tx.timestamp).toBeInstanceOf(Date);
});
});
});
// β
Testing state consistency with private methods
describe('Testing state consistency', () => {
it('should maintain consistent internal state', () => {
// Perform various operations
account.deposit(500); // Balance: 1500
account.withdraw(200); // Balance: 1300
account.deposit(100); // Balance: 1400
account.withdraw(300); // Balance: 1100
// Verify final state consistency
expect(account.balance).toBe(1100);
expect(account.transactionHistory).toHaveLength(4);
expect(account.dailyWithdrawalsRemaining).toBe(500); // 1000 - 500 withdrawn
// Verify transaction balance consistency
const transactions = account.transactionHistory;
let expectedBalance = 1000; // Initial balance
transactions.forEach(tx => {
expect(tx.balanceBefore).toBe(expectedBalance);
if (tx.type === 'deposit') {
expectedBalance += tx.amount;
} else {
expectedBalance -= tx.amount;
}
expect(tx.balanceAfter).toBe(expectedBalance);
});
expect(expectedBalance).toBe(account.balance);
});
it('should handle edge cases in state management', () => {
// Test boundary conditions
account.withdraw(1000); // Withdraw entire balance
expect(account.balance).toBe(0);
// Should still be able to deposit
account.deposit(1);
expect(account.balance).toBe(1);
// Should not be able to withdraw more than balance
expect(() => account.withdraw(2))
.toThrow('Insufficient funds');
});
});
// β
Testing private method interactions
describe('Private method interactions', () => {
it('should test interaction between validation methods', () => {
// Lock account
account.lockAccount();
// All validation should fail on locked account
expect(() => account.deposit(100))
.toThrow('Account is locked');
expect(() => account.withdraw(100))
.toThrow('Account is locked');
// Unlock and test amount validation
account.unlockAccount();
expect(() => account.deposit(-100))
.toThrow('Amount must be positive');
expect(() => account.withdraw(0))
.toThrow('Amount must be positive');
});
it('should test validation order in withdrawal', () => {
// Set up scenario where multiple validations could fail
account.setDailyWithdrawalLimit(500);
account.withdraw(400); // Use up most daily limit
// Test that amount validation happens first
expect(() => account.withdraw(-100))
.toThrow('Amount must be positive'); // Not daily limit error
// Test that account lock validation happens next
account.lockAccount();
expect(() => account.withdraw(50))
.toThrow('Account is locked'); // Not insufficient funds or daily limit
// Test sufficient funds validation
account.unlockAccount();
expect(() => account.withdraw(700))
.toThrow('Insufficient funds'); // Not daily limit (would be checked after)
// Test daily limit validation
expect(() => account.withdraw(200))
.toThrow('Daily withdrawal limit exceeded');
});
});
});
// β
Testing encapsulation boundaries
describe('Encapsulation Testing', () => {
let account: BankAccount;
beforeEach(() => {
account = new BankAccount(1000, 'TEST123');
});
it('should not expose internal state directly', () => {
// Verify private fields are not accessible
expect((account as any)._balance).toBeUndefined();
expect((account as any)._transactions).toBeUndefined();
expect((account as any)._accountNumber).toBeUndefined();
// Access should only be through public interface
expect(account.balance).toBe(1000);
expect(account.accountNumber).toBe('TEST123');
expect(account.transactionHistory).toBeDefined();
});
it('should return immutable copies of internal data', () => {
// Add some transactions
account.deposit(100);
account.withdraw(50);
// Get transaction history
const history1 = account.transactionHistory;
const history2 = account.transactionHistory;
// Should be different array instances
expect(history1).not.toBe(history2);
// But with same content
expect(history1).toEqual(history2);
// Modifying returned array shouldn't affect internal state
const originalLength = history1.length;
// This should not affect the account's internal state
expect(() => {
(history1 as any).push({ fake: 'transaction' });
}).toThrow(); // Should be immutable
// Verify internal state unchanged
expect(account.transactionHistory).toHaveLength(originalLength);
});
it('should control access through methods only', () => {
// Balance should only change through deposit/withdraw methods
const initialBalance = account.balance;
// Cannot directly modify balance
expect(() => {
(account as any).balance = 5000;
}).not.toThrow(); // Assignment might not throw, but shouldn't change internal state
// Public balance should still reflect internal state
expect(account.balance).toBe(initialBalance);
// Only way to change balance is through public methods
account.deposit(500);
expect(account.balance).toBe(initialBalance + 500);
});
it('should validate all inputs at public interface', () => {
// Every public method should validate its inputs
const invalidInputs = [NaN, Infinity, -1, 0, null, undefined];
invalidInputs.forEach(invalidInput => {
if (typeof invalidInput === 'number') {
expect(() => account.deposit(invalidInput))
.toThrow();
expect(() => account.withdraw(invalidInput))
.toThrow();
}
});
});
});
π― Advanced Testing Patterns
π Production-Ready Class Testing
Here are advanced patterns for comprehensive class testing in production applications.
// π Advanced class testing patterns
describe('Advanced Class Testing Patterns', () => {
// β
Testing class state machines
describe('State Machine Testing', () => {
enum AccountState {
ACTIVE = 'active',
LOCKED = 'locked',
SUSPENDED = 'suspended',
CLOSED = 'closed'
}
class StatefulBankAccount extends BankAccount {
private _state: AccountState = AccountState.ACTIVE;
get state(): AccountState {
return this._state;
}
setState(newState: AccountState): void {
if (!this.isValidStateTransition(this._state, newState)) {
throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
}
this._state = newState;
}
deposit(amount: number, description?: string): void {
if (this._state !== AccountState.ACTIVE) {
throw new Error(`Cannot deposit: account is ${this._state}`);
}
super.deposit(amount, description);
}
withdraw(amount: number, description?: string): void {
if (this._state !== AccountState.ACTIVE) {
throw new Error(`Cannot withdraw: account is ${this._state}`);
}
super.withdraw(amount, description);
}
private isValidStateTransition(from: AccountState, to: AccountState): boolean {
const validTransitions: Record<AccountState, AccountState[]> = {
[AccountState.ACTIVE]: [AccountState.LOCKED, AccountState.SUSPENDED, AccountState.CLOSED],
[AccountState.LOCKED]: [AccountState.ACTIVE, AccountState.SUSPENDED, AccountState.CLOSED],
[AccountState.SUSPENDED]: [AccountState.ACTIVE, AccountState.CLOSED],
[AccountState.CLOSED]: [] // No transitions from closed state
};
return validTransitions[from].includes(to);
}
}
let statefulAccount: StatefulBankAccount;
beforeEach(() => {
statefulAccount = new StatefulBankAccount(1000, 'STATE123');
});
it('should start in active state', () => {
expect(statefulAccount.state).toBe(AccountState.ACTIVE);
});
it('should allow valid state transitions', () => {
// Active -> Locked
statefulAccount.setState(AccountState.LOCKED);
expect(statefulAccount.state).toBe(AccountState.LOCKED);
// Locked -> Active
statefulAccount.setState(AccountState.ACTIVE);
expect(statefulAccount.state).toBe(AccountState.ACTIVE);
// Active -> Suspended
statefulAccount.setState(AccountState.SUSPENDED);
expect(statefulAccount.state).toBe(AccountState.SUSPENDED);
// Suspended -> Closed
statefulAccount.setState(AccountState.CLOSED);
expect(statefulAccount.state).toBe(AccountState.CLOSED);
});
it('should reject invalid state transitions', () => {
// Close account
statefulAccount.setState(AccountState.CLOSED);
// Should not be able to transition from closed state
expect(() => statefulAccount.setState(AccountState.ACTIVE))
.toThrow('Invalid state transition from closed to active');
expect(() => statefulAccount.setState(AccountState.LOCKED))
.toThrow('Invalid state transition from closed to locked');
});
it('should restrict operations based on state', () => {
// Lock account
statefulAccount.setState(AccountState.LOCKED);
// Operations should be rejected
expect(() => statefulAccount.deposit(100))
.toThrow('Cannot deposit: account is locked');
expect(() => statefulAccount.withdraw(100))
.toThrow('Cannot withdraw: account is locked');
// Unlock and verify operations work
statefulAccount.setState(AccountState.ACTIVE);
expect(() => statefulAccount.deposit(100)).not.toThrow();
expect(() => statefulAccount.withdraw(100)).not.toThrow();
});
it('should test all possible state transitions', () => {
const allStates = Object.values(AccountState);
const testedTransitions: Array<[AccountState, AccountState]> = [];
// Test all valid transitions
allStates.forEach(fromState => {
allStates.forEach(toState => {
if (fromState !== toState) {
// Reset to fromState
statefulAccount = new StatefulBankAccount(1000, 'STATE123');
if (fromState !== AccountState.ACTIVE) {
// Navigate to fromState (simplified for test)
try {
statefulAccount.setState(fromState);
} catch {
// Some states might not be reachable from ACTIVE
return;
}
}
// Try transition
try {
statefulAccount.setState(toState);
testedTransitions.push([fromState, toState]);
} catch (error) {
// Invalid transition - that's expected for some combinations
}
}
});
});
// Verify we tested the expected valid transitions
expect(testedTransitions).toContainEqual([AccountState.ACTIVE, AccountState.LOCKED]);
expect(testedTransitions).toContainEqual([AccountState.ACTIVE, AccountState.SUSPENDED]);
expect(testedTransitions).toContainEqual([AccountState.LOCKED, AccountState.ACTIVE]);
});
});
// β
Testing performance characteristics
describe('Performance Testing', () => {
it('should handle large transaction volumes efficiently', () => {
const account = new BankAccount(10000);
const transactionCount = 1000;
const startTime = Date.now();
// Perform many transactions
for (let i = 0; i < transactionCount; i++) {
if (i % 2 === 0) {
account.deposit(10, `Deposit ${i}`);
} else {
account.withdraw(5, `Withdrawal ${i}`);
}
}
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete within reasonable time
expect(duration).toBeLessThan(1000); // 1 second
// Verify all transactions were recorded
expect(account.transactionHistory).toHaveLength(transactionCount);
// Verify final balance is correct
const expectedBalance = 10000 + (transactionCount / 2 * 10) - (transactionCount / 2 * 5);
expect(account.balance).toBe(expectedBalance);
});
it('should handle memory efficiently with large transaction history', () => {
const account = new BankAccount(1000);
// Add many transactions
for (let i = 0; i < 10000; i++) {
account.deposit(1, `Transaction ${i}`);
}
// Memory usage should not be excessive
const history = account.transactionHistory;
expect(history).toHaveLength(10000);
// Getting history multiple times should not accumulate memory
const history1 = account.transactionHistory;
const history2 = account.transactionHistory;
expect(history1).toEqual(history2);
expect(history1).not.toBe(history2); // Different instances
});
});
// β
Testing error recovery
describe('Error Recovery Testing', () => {
let account: PremiumBankAccount;
let mockLogger: jest.Mocked<Logger>;
let mockNotificationService: jest.Mocked<NotificationService>;
let mockAuditService: jest.Mocked<AuditService>;
beforeEach(() => {
mockLogger = {
log: jest.fn(),
getLastLogMessage: jest.fn()
};
mockNotificationService = {
sendNotification: jest.fn(),
getNotificationHistory: jest.fn()
};
mockAuditService = {
recordEvent: jest.fn(),
getAuditTrail: jest.fn()
};
account = new PremiumBankAccount(
1000,
'ERROR123',
mockLogger,
mockNotificationService,
mockAuditService
);
});
it('should handle logger failures gracefully', () => {
// Arrange
mockLogger.log.mockImplementation(() => {
throw new Error('Logger is down');
});
// Act & Assert - operation should still succeed despite logger failure
// (This depends on implementation - might need to catch logger errors)
expect(() => account.deposit(100)).toThrow('Logger is down');
// Alternative: if logger errors are caught internally
// expect(() => account.deposit(100)).not.toThrow();
// expect(account.balance).toBe(1100);
});
it('should handle notification failures gracefully', async () => {
// Arrange
mockNotificationService.sendNotification.mockRejectedValue(
new Error('Notification service unavailable')
);
// Act
account.withdraw(1500); // Should trigger notification
// Wait for async notification attempt
await new Promise(resolve => setTimeout(resolve, 10));
// Assert - withdrawal should succeed despite notification failure
expect(account.balance).toBe(-500);
expect(mockLogger.log).toHaveBeenCalledWith(
'Failed to send notification: Notification service unavailable',
'error'
);
});
it('should handle audit service failures', () => {
// Arrange
mockAuditService.recordEvent.mockImplementation(() => {
throw new Error('Audit service is down');
});
// Act & Assert - depends on implementation
// If audit is critical, operation should fail
expect(() => account.withdraw(100)).toThrow('Audit service is down');
// If audit failure is logged but operation continues:
// expect(() => account.withdraw(100)).not.toThrow();
// expect(mockLogger.log).toHaveBeenCalledWith(...);
});
it('should maintain consistency during partial failures', async () => {
// Arrange - notification fails, but audit succeeds
mockNotificationService.sendNotification.mockRejectedValue(new Error('Service down'));
mockAuditService.recordEvent.mockImplementation(); // Succeeds
// Act
account.withdraw(1200);
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 10));
// Assert - operation should complete with consistent state
expect(account.balance).toBe(-200);
expect(mockAuditService.recordEvent).toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Failed to send notification'),
'error'
);
});
});
// β
Testing thread safety (if applicable)
describe('Concurrency Testing', () => {
it('should handle concurrent operations safely', async () => {
const account = new BankAccount(1000);
const operationCount = 100;
const operations: Promise<void>[] = [];
// Create many concurrent operations
for (let i = 0; i < operationCount; i++) {
if (i % 2 === 0) {
operations.push(
new Promise<void>(resolve => {
setTimeout(() => {
try {
account.deposit(10);
} catch (error) {
// Handle any errors
}
resolve();
}, Math.random() * 10);
})
);
} else {
operations.push(
new Promise<void>(resolve => {
setTimeout(() => {
try {
account.withdraw(5);
} catch (error) {
// Handle insufficient funds or other errors
}
resolve();
}, Math.random() * 10);
})
);
}
}
// Wait for all operations to complete
await Promise.all(operations);
// Verify final state is consistent
const history = account.transactionHistory;
expect(history.length).toBeGreaterThan(0);
// Verify balance consistency with transaction history
let calculatedBalance = 1000;
history.forEach(tx => {
if (tx.type === 'deposit') {
calculatedBalance += tx.amount;
} else {
calculatedBalance -= tx.amount;
}
});
expect(account.balance).toBe(calculatedBalance);
});
});
// β
Testing integration scenarios
describe('Integration Testing', () => {
it('should test complex multi-account scenarios', () => {
// Create multiple accounts
const account1 = new BankAccount(1000, 'ACC001');
const account2 = new BankAccount(500, 'ACC002');
const account3 = new BankAccount(2000, 'ACC003');
// Perform complex operations
account1.transfer(300, account2, 'Payment to ACC002');
account2.transfer(200, account3, 'Payment to ACC003');
account3.transfer(500, account1, 'Refund to ACC001');
// Verify final balances
expect(account1.balance).toBe(1200); // 1000 - 300 + 500
expect(account2.balance).toBe(600); // 500 + 300 - 200
expect(account3.balance).toBe(1700); // 2000 + 200 - 500
// Verify transaction histories
expect(account1.transactionHistory).toHaveLength(2); // 1 withdrawal, 1 deposit
expect(account2.transactionHistory).toHaveLength(2); // 1 deposit, 1 withdrawal
expect(account3.transactionHistory).toHaveLength(2); // 1 deposit, 1 withdrawal
});
it('should test business workflow scenarios', () => {
const savingsAccount = new SavingsAccount('SAV001', 'John Doe', 0.05);
// Simulate monthly activities
for (let month = 1; month <= 12; month++) {
// Monthly salary deposit
savingsAccount.deposit(5000);
// Monthly expenses
savingsAccount.withdraw(3000);
// Apply monthly interest
savingsAccount.applyInterest();
}
// After 12 months, verify state
expect(savingsAccount.getBalance()).toBeGreaterThan(24000); // Base amount + interest
// Verify reasonable interest was earned
const expectedMinBalance = 12 * (5000 - 3000); // 24000
const actualBalance = savingsAccount.getBalance();
const interestEarned = actualBalance - expectedMinBalance;
expect(interestEarned).toBeGreaterThan(1000); // Should have earned significant interest
});
});
});
π Conclusion
Congratulations! Youβve mastered the art of testing TypeScript classes and object-oriented patterns! π―
π Key Takeaways
- Constructor Testing: Verify proper initialization and parameter validation
- Property Testing: Test getters, setters, and state management
- Method Testing: Comprehensive testing of behavior and side effects
- Inheritance Testing: Verify parent/child class relationships and overrides
- Dependency Injection: Mock external dependencies effectively
- Encapsulation: Test through public interfaces while respecting boundaries
- State Management: Verify state transitions and consistency
- Error Handling: Test failure scenarios and recovery mechanisms
π Next Steps
- Testing React Components: Learn component testing with React Testing Library
- Testing Hooks: Master custom React hook testing patterns
- Integration Testing: Build comprehensive integration test suites
- End-to-End Testing: Test complete user workflows with Cypress/Playwright
- Testing APIs: Learn backend testing patterns for TypeScript services
You now have the skills to test any TypeScript class hierarchy with confidence, ensuring your object-oriented code is robust, maintainable, and bug-free! π