Use Case
Processing a loan application
Add the following test cases to process-loan-application.spec.ts
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
it.todo('fetches the borrowers profile from the database', async () => {
await processLoanApplication(processLoanApplicationInput);
expect(getBorrowerProfileSpy).toHaveBeenCalledWith(
processLoanApplicationInput.borrowerEmail
);
});
describe('given the borrower profile is not found', () => {
it.todo('rejects with borrower not found error', () => {
getBorrowerProfileSpy.mockResolvedValue(undefined);
return expect(
processLoanApplication(processLoanApplicationInput)
).rejects.toBeInstanceOf(BorrowerProfileDoesNotExistError);
});
});
describe('given the get borrower profile adaptor rejects with an internal error', () => {
it.todo('rejects with the error', () => {
});
});
describe('given the borrower profile is successfully retrieved', () => {
it.todo(
'calls the assess loan application use case with the ' +
'borrowers age, employment status, gross annual income, monthly expenses and credit score',
async () => {
}
);
it.todo('writes the loan application to the database', async () => {
});
describe('given the write to the database succeeds', () => {
it.todo('resolves with the loan application status', () => {
});
});
describe('given the write to the database rejects', () => {
it.todo('rejects with the error', () => {
});
});
});
});
});
Faking System Time
The calculation of the borrower's age is time dependent. To avoid the borrower`s calculated age changing, based on our system time, we need to mock the system time.
describe('process loan-application', () => {
const systemTime = new Date(2024, 2, 15);
const mockedTimestamp = systemTime.toISOString();
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(systemTime);
});
afterAll(() => {
vi.useRealTimers();
});
// ...
We're going to use vi-test
fake timers to fix the system date time to 15th of March 2024.
Note: Months are 0-indexed, so March will be 2, not 3. Days funnily enough, are not 1-indexed. Go figure...
describe('process loan-application', () => {
const systemTime = new Date(2024, 2, 15);
const mockedTimestamp = systemTime.toISOString();
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(systemTime);
});
afterAll(() => {
vi.useRealTimers();
});
// ...
Mocking the adapters
Because this is a unit test for the process-loan-application
use case, we're only focussed on verifying the orchestration of the various tasks performed by the use case.
So, we don't want to exercise the secondary DynamoDB adapters used to get a borrower profile and put a loan application. To do this we'll need to mock out both the ddb-get-borrower-profile
and ddb-put-loan-application
adapters:
describe('process loan-application', () => {
// ...
vi.mock('@/adaptors/secondary/ddb-get-borrower-profile', () => ({
getBorrowerProfile: vi.fn(() => Promise.resolve(undefined)),
}));
const getBorrowerProfileSpy = vi.mocked(getBorrowerProfile);
vi.mock('@/adaptors/secondary/ddb-put-loan-application', () => ({
putLoanApplication: vi.fn(() => Promise.resolve(undefined)),
}));
const putLoanApplicationSpy = vi.mocked(putLoanApplication);
vi.mock('./assess-loan-application', () => ({
assessLoanApplication: vi.fn(() => Promise.resolve(undefined)),
}));
const assessLoanApplicationSpy = vi.mocked(assessLoanApplication);
beforeEach(() => {
vi.resetAllMocks();
getBorrowerProfileSpy.mockResolvedValue(borrowerProfile);
assessLoanApplicationSpy.mockReturnValue('APPROVED');
});
// ...
Additionally, we're not interested in testing the business logic, as it relates to assessing a loan application. We'll also need to mock out the calls to assessLoanApplication
.
describe('process loan-application', () => {
// ...
vi.mock('@/adaptors/secondary/ddb-get-borrower-profile', () => ({
getBorrowerProfile: vi.fn(() => Promise.resolve(undefined)),
}));
const getBorrowerProfileSpy = vi.mocked(getBorrowerProfile);
vi.mock('@/adaptors/secondary/ddb-put-loan-application', () => ({
putLoanApplication: vi.fn(() => Promise.resolve(undefined)),
}));
const putLoanApplicationSpy = vi.mocked(putLoanApplication);
vi.mock('./assess-loan-application', () => ({
assessLoanApplication: vi.fn(() => Promise.resolve(undefined)),
}));
const assessLoanApplicationSpy = vi.mocked(assessLoanApplication);
beforeEach(() => {
vi.resetAllMocks();
getBorrowerProfileSpy.mockResolvedValue(borrowerProfile);
assessLoanApplicationSpy.mockReturnValue('APPROVED');
});
// ...
Verify Retrieval of Borrower Profile
Unskip the test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
it('fetches the borrowers profile from the database', async () => {
await processLoanApplication(processLoanApplicationInput);
expect(getBorrowerProfileSpy).toHaveBeenCalledWith(
processLoanApplicationInput.borrowerEmail
);
});
// ...
});
});
Note the currently failing test in your terminal:
Next, make this test pass. To this, you'll need to get the borrower profile using the correct secondary adapter.
Hint
Solution
import { getBorrowerProfile } from '@/adaptors/secondary/ddb-get-borrower-profile';
import { putLoanApplication } from '@/adaptors/secondary/ddb-put-loan-application';
import { BorrowerProfileDoesNotExistError } from '@/errors/BorrowerProfileDoesNotExistError';
import { ProcessLoanApplicationPort } from '@/ports/primary/ProcessLoanApplication';
import { getYearsSinceCurrentDate } from '@/utils/get-years-since-current-date';
import { randomUUID } from 'crypto';
import { assessLoanApplication } from './assess-loan-application';
const calculateAge = (dateOfBirth: Date): number =>
getYearsSinceCurrentDate(dateOfBirth);
export const processLoanApplication: ProcessLoanApplicationPort = async ({
borrowerEmail,
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
}) => {
const borrowerProfile = await getBorrowerProfile(borrowerEmail);
}
Handle Non-Existent Borrower Profile
Unskip the next test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is not found', () => {
it('rejects with borrower not found error', () => {
getBorrowerProfileSpy.mockResolvedValue(undefined);
return expect(
processLoanApplication(processLoanApplicationInput)
).rejects.toBeInstanceOf(BorrowerProfileDoesNotExistError);
});
});
// ...
});
});
Start the test watcher
If your tests are not running, issue the following command:
pnpm run test
You should see a failing test case, similar to the following:
Make the failing unit test pass
Navigate to backend/src/adaptors/secondary/ddb-put-loan-application.ts
.
Next, make this test pass.
Hint
BorrowerProfileDoesNotExistError
error if the borrowerProfile
.Solution
import { getBorrowerProfile } from '@/adaptors/secondary/ddb-get-borrower-profile';
import { putLoanApplication } from '@/adaptors/secondary/ddb-put-loan-application';
import { BorrowerProfileDoesNotExistError } from '@/errors/BorrowerProfileDoesNotExistError';
import { ProcessLoanApplicationPort } from '@/ports/primary/ProcessLoanApplication';
import { getYearsSinceCurrentDate } from '@/utils/get-years-since-current-date';
import { randomUUID } from 'crypto';
import { assessLoanApplication } from './assess-loan-application';
const calculateAge = (dateOfBirth: Date): number =>
getYearsSinceCurrentDate(dateOfBirth);
export const processLoanApplication: ProcessLoanApplicationPort = async ({
borrowerEmail,
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
}) => {
const borrowerProfile = await getBorrowerProfile(borrowerEmail);
if (!borrowerProfile) {
throw new BorrowerProfileDoesNotExistError('Borrower profile not found');
}
}
Handle Internal Error in Borrower Profile Adapter
Unskip the next test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the get borrower profile adaptor rejects with an internal error', () => {
it('rejects with the error', () => {
const error = new InternalError('Cheers Kent');
getBorrowerProfileSpy.mockRejectedValue(error);
return expect(
processLoanApplication(processLoanApplicationInput)
).rejects.toBe(error);
});
});
// ...
});
});
Start the test watcher
If your tests are not running, issue the following command:
pnpm run test
You should see a passing test case, similar to the following:
Note: The reason this test passes without any change required is because the secondary adapter (specification:
ddb-get-borrower-profile.spec.ts
) already throws anInternalError
if a borrower profile cannot be found.
Verify Loan Processing
Unskip the next test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is successfully retrieved', () => {
it(
'calls the assess loan application use case with the ' +
'borrowers age, employment status, gross annual income, monthly expenses and credit score',
async () => {}
);
// ...
});
// ...
});
});
Next, write a failing test.
Hint
toHaveBeenCalledWith
matcher.Solution
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is successfully retrieved', () => {
it(
'calls the assess loan application use case with the ' +
'borrowers age, employment status, gross annual income, monthly expenses and credit score',
async () => {
await processLoanApplication(processLoanApplicationInput);
expect(assessLoanApplicationSpy).toHaveBeenCalledWith({
age: 42,
creditScore: borrowerProfile.creditScore,
employmentStatus: processLoanApplicationInput.employmentStatus,
grossAnnualIncome: processLoanApplicationInput.grossAnnualIncome,
monthlyExpenses: processLoanApplicationInput.monthlyExpenses,
});
}
);
// ...
});
// ...
});
});
Start the test watcher
If your tests are not running, issue the following command:
pnpm run test
You should see a failing test case, similar to the following:
Make the failing unit test pass
Navigate to backend/src/adaptors/secondary/ddb-put-loan-application.ts
.
Next, make this test pass.
The assessLoanApplication
function should be used to determine the status of the loan application.
Hint
assessLoanApplication
to understand how to use it. It's located here: backend/src/use-cases/loan-assessment/assess-loan-application.spec.ts
Hint
calculateAge
function to compute the borrower's age from their date of birth.Solution
import { getBorrowerProfile } from '@/adaptors/secondary/ddb-get-borrower-profile';
import { putLoanApplication } from '@/adaptors/secondary/ddb-put-loan-application';
import { BorrowerProfileDoesNotExistError } from '@/errors/BorrowerProfileDoesNotExistError';
import { ProcessLoanApplicationPort } from '@/ports/primary/ProcessLoanApplication';
import { getYearsSinceCurrentDate } from '@/utils/get-years-since-current-date';
import { randomUUID } from 'crypto';
import { assessLoanApplication } from './assess-loan-application';
const calculateAge = (dateOfBirth: Date): number =>
getYearsSinceCurrentDate(dateOfBirth);
export const processLoanApplication: ProcessLoanApplicationPort = async ({
borrowerEmail,
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
}) => {
const borrowerProfile = await getBorrowerProfile(borrowerEmail);
if (!borrowerProfile) {
throw new BorrowerProfileDoesNotExistError('Borrower profile not found');
}
const { creditScore, dob } = borrowerProfile;
const loanApplicationStatus = assessLoanApplication({
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
creditScore,
age: calculateAge(new Date(dob)),
});
}
Verify Peristence of Loan Application
Unskip the next test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is successfully retrieved', () => {
it('writes the loan application to the database', async () => {
});
// ...
});
// ...
});
});
Next, write a failing test.
Hint
expect.any(String)
when asserting against the loanApplicationId
.Hint
timestamp
as we have already mocked the system time.Solution
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is successfully retrieved', () => {
it('writes the loan application to the database', async () => {
await processLoanApplication(processLoanApplicationInput);
expect(putLoanApplicationSpy).toHaveBeenCalledWith({
loanApplicationId: expect.any(String),
borrowerEmail: processLoanApplicationInput.borrowerEmail,
creditScore: borrowerProfile.creditScore,
employmentStatus: processLoanApplicationInput.employmentStatus,
grossAnnualIncome: processLoanApplicationInput.grossAnnualIncome,
loanApplicationStatus: 'APPROVED',
monthlyExpenses: processLoanApplicationInput.monthlyExpenses,
timestamp: mockedTimestamp,
});
});
// ...
});
// ...
});
});
Start the test watcher
If your tests are not running, issue the following command:
pnpm run test
You should see a failing test case, similar to the following:
Make the failing unit test pass
Navigate to backend/src/adaptors/secondary/ddb-put-loan-application.ts
.
Next, make this test pass.
The putLoanApplicationFunction
function should be used to persist the loan application. Use the node:crypto
module to generate a V4 UUID.
Solution
import { getBorrowerProfile } from '@/adaptors/secondary/ddb-get-borrower-profile';
import { putLoanApplication } from '@/adaptors/secondary/ddb-put-loan-application';
import { BorrowerProfileDoesNotExistError } from '@/errors/BorrowerProfileDoesNotExistError';
import { ProcessLoanApplicationPort } from '@/ports/primary/ProcessLoanApplication';
import { getYearsSinceCurrentDate } from '@/utils/get-years-since-current-date';
import { randomUUID } from 'crypto';
import { assessLoanApplication } from './assess-loan-application';
const calculateAge = (dateOfBirth: Date): number =>
getYearsSinceCurrentDate(dateOfBirth);
export const processLoanApplication: ProcessLoanApplicationPort = async ({
borrowerEmail,
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
}) => {
const borrowerProfile = await getBorrowerProfile(borrowerEmail);
if (!borrowerProfile) {
throw new BorrowerProfileDoesNotExistError('Borrower profile not found');
}
const { creditScore, dob } = borrowerProfile;
const loanApplicationStatus = assessLoanApplication({
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
creditScore,
age: calculateAge(new Date(dob)),
});
await putLoanApplication({
borrowerEmail,
loanApplicationId: randomUUID(),
timestamp: new Date().toISOString(),
creditScore,
grossAnnualIncome,
monthlyExpenses,
loanApplicationStatus,
employmentStatus,
});
}
Verify Loan Application Status Is Returned
Unskip the next test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is successfully retrieved', () => {
it('writes the loan application to the database', async () => {
});
// ...
});
// ...
});
});
Next, write a failing test.
Solution
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the borrower profile is successfully retrieved', () => {
describe('given the write to the database succeeds', () => {
it('resolves with the loan application status', () => {
return expect(
processLoanApplication(processLoanApplicationInput)
).resolves.toEqual('APPROVED');
});
});
// ...
});
// ...
});
});
Start the test watcher
If your tests are not running, issue the following command:
pnpm run test
You should see a failing test case, similar to the following:
Make the failing unit test pass
Navigate to backend/src/adaptors/secondary/ddb-put-loan-application.ts
.
Next, make this test pass.
Hint
Solution
import { getBorrowerProfile } from '@/adaptors/secondary/ddb-get-borrower-profile';
import { putLoanApplication } from '@/adaptors/secondary/ddb-put-loan-application';
import { BorrowerProfileDoesNotExistError } from '@/errors/BorrowerProfileDoesNotExistError';
import { ProcessLoanApplicationPort } from '@/ports/primary/ProcessLoanApplication';
import { getYearsSinceCurrentDate } from '@/utils/get-years-since-current-date';
import { randomUUID } from 'crypto';
import { assessLoanApplication } from './assess-loan-application';
const calculateAge = (dateOfBirth: Date): number =>
getYearsSinceCurrentDate(dateOfBirth);
export const processLoanApplication: ProcessLoanApplicationPort = async ({
borrowerEmail,
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
}) => {
const borrowerProfile = await getBorrowerProfile(borrowerEmail);
if (!borrowerProfile) {
throw new BorrowerProfileDoesNotExistError('Borrower profile not found');
}
const { creditScore, dob } = borrowerProfile;
const loanApplicationStatus = assessLoanApplication({
grossAnnualIncome,
employmentStatus,
monthlyExpenses,
creditScore,
age: calculateAge(new Date(dob)),
});
await putLoanApplication({
borrowerEmail,
loanApplicationId: randomUUID(),
timestamp: new Date().toISOString(),
creditScore,
grossAnnualIncome,
monthlyExpenses,
loanApplicationStatus,
employmentStatus,
});
return loanApplicationStatus;
}
Handle Internal Error in Loan Application Adapter
Unskip the next test case. Remove the .todo
: it.todo(...)
--> it(...)
:
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the write to the database rejects', () => {
it('rejects with the error', () => {
});
});
// ...
});
});
Next, write a failing test.
Solution
// ...
describe('process loan-application', () => {
// ...
describe('given a borrower email, gross annual income, employment status and monthly expenses', () => {
// ...
describe('given the write to the database rejects', () => {
it('rejects with the error', () => {
const error = new InternalError('Cheers Clint');
putLoanApplicationSpy.mockRejectedValue(error);
return expect(
processLoanApplication(processLoanApplicationInput)
).rejects.toBe(error);
});
});
// ...
});
});
Start the test watcher
If your tests are not running, issue the following command:
pnpm run test
You should see a passing test case, similar to the following:
Note: The reason this test passes without any change required is because the secondary adapter (specification:
ddb-put-loan-application.spec.ts
) already throws anInternalError
if a loan application cannot be persisted.