Skip to main content

Use Case

Processing a loan application

Add the following test cases to process-loan-application.spec.ts:

backend/src/use-cases/loan-assessment/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.

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
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...

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
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:

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
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.

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
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(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

Failing secondary adapter test - fetch borrower from database

Next, make this test pass. To this, you'll need to get the borrower profile using the correct secondary adapter.

Hint
Take a look at `backend/src/ports/secondary/DynamoDBGetBorrowerProfile.ts`.
Solution
backend/src/use-cases/loan-assessment/process-loan-application.ts
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(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

2nd Failing `Apply for Loan` unit test

Make the failing unit test pass

Navigate to backend/src/adaptors/secondary/ddb-put-loan-application.ts.

Next, make this test pass.

Hint
To this, you'll need to throw a BorrowerProfileDoesNotExistError error if the borrowerProfile.
Solution
backend/src/use-cases/loan-assessment/process-loan-application.ts
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(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

3rd Passing `Apply for Loan` unit test

Note: The reason this test passes without any change required is because the secondary adapter (specification: ddb-get-borrower-profile.spec.ts) already throws an InternalError if a borrower profile cannot be found.

Verify Loan Processing

Unskip the next test case. Remove the .todo: it.todo(...) --> it(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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
You'll need to use the toHaveBeenCalledWith matcher.
Solution
backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

4th Failing `Apply for Loan` unit test

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
Take a look at the specification of assessLoanApplication to understand how to use it. It's located here: backend/src/use-cases/loan-assessment/assess-loan-application.spec.ts
Hint
You'll need to use the calculateAge function to compute the borrower's age from their date of birth.
Solution
backend/src/use-cases/loan-assessment/process-loan-application.ts
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(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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
Use the expect.any(String) when asserting against the loanApplicationId.
Hint
We know the value of the timestamp as we have already mocked the system time.
Solution
backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

5th Failing `Apply for Loan` unit test

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
backend/src/use-cases/loan-assessment/process-loan-application.ts
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(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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
backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

6th Failing `Apply for Loan` unit test

Make the failing unit test pass

Navigate to backend/src/adaptors/secondary/ddb-put-loan-application.ts.

Next, make this test pass.

Hint
Return the result from the call to `putLoanApplicationFunction`.
Solution
backend/src/use-cases/loan-assessment/process-loan-application.ts
  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(...):

backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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
backend/src/use-cases/loan-assessment/process-loan-application.spec.ts
// ...
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:

7th Passing `Apply for Loan` unit test

Note: The reason this test passes without any change required is because the secondary adapter (specification: ddb-put-loan-application.spec.ts) already throws an InternalError if a loan application cannot be persisted.