Skip to content

Commit b303713

Browse files
committed
Adding unit tests for the non-hexagonal serverless application
1 parent 4272e67 commit b303713

File tree

3 files changed

+270
-11
lines changed

3 files changed

+270
-11
lines changed

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ This project has the following folder structure:
2020
├── package.json
2121
├── samconfig.toml # AWS SAM config file, generated by SAM
2222
├── src # Source code for all functions
23-
│   └── parse-covid-csv # Fuction source code
24-
│   ├── events # Events for local testing
25-
│   │   ├── context.ts
26-
│   │   └── event.json
27-
│   └── lambda.ts # Function source code
28-
├── template.yaml # Main CloudFormation file
23+
│   ├── parse-covid-csv # Fuction source code
24+
│   │ ├── events # Events for local testing
25+
│   │ │   ├── context.ts
26+
│   │ │   └── event.json
27+
│   │ └── lambda.ts # Function source code
28+
│   └── tests
29+
│ └── parse-covid-csv
30+
│ └── lambda.test.ts
31+
├── template.yaml # Main CloudFormation file
2932
├── tsconfig.json
3033
├── webpack.config.js # Webpack config
3134
└── yarn.lock
@@ -79,8 +82,16 @@ sam deploy
7982

8083
### Testing
8184

82-
This section will be added tomorrow.
85+
To run the application tests, run the following command:
86+
87+
```bash
88+
npm test
89+
```
90+
91+
This will run the jest tests for the sample serverless component, particularly the [`/src/tests/parse-covid-csv/lambda.test.ts`](./src/tests/parse-covid-csv/lambda.test.ts).
92+
93+
Note. If you notice error logs during the test run, please do not get concerned, as the tests are also evaluating the error cases and it is recommended to trace the particular error logs.
8394

8495
## License
8596

86-
MIT, see [LICENSE](LICENSE).
97+
MIT, see [LICENSE](LICENSE).

package-lock.json

Lines changed: 21 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { Readable, Writable } from 'stream'
2+
import { PutItemInput } from 'aws-sdk/clients/dynamodb'
3+
import { GetObjectRequest } from 'aws-sdk/clients/s3'
4+
import s3Event from '../../parse-covid-csv/events/event.json'
5+
6+
interface IMockGetObject {
7+
createReadStream: jest.Mock
8+
}
9+
10+
// This file represents common unit tests we see in projects.
11+
// These are not the best testing examples, you'll see the better approach
12+
// using hexagonal architecture in the following commits.
13+
describe('Parse Covid CSV', () => {
14+
// We need to be able to check if these mocks are invoked in multiple tests,
15+
// so we define them outside of the beforeEach block
16+
const mockedS3GetObject = jest.fn()
17+
const mockedDynamoDBPut = jest.fn()
18+
const lambdaPath = '../../parse-covid-csv/lambda'
19+
20+
// Before each test, we'll create mocks of required libraries and modules.
21+
// We can do this before all tests, but as we are using Node streams,
22+
// we can't write to the stream that is already closed,
23+
// so we'll re-create our mocks before each run.
24+
// This is not an optimal solution, but it works.
25+
beforeEach(function () {
26+
// We can try to use the aws-sdk-mock library, but we need to mock fs too.
27+
// It's easier to mock everything using the built-in Jest functionality.
28+
// We are mocking the "clients/s3" path of the "aws-sdk" module,
29+
// because our app imports this path
30+
jest.mock('aws-sdk/clients/s3', () => {
31+
// Instead of a real class, we'll return a mock class here
32+
return class S3 {
33+
// We only use the "getObject" method, so we mock that one only.
34+
// We do not care about other methods.
35+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
36+
getObject(_params: GetObjectRequest, _cb: () => {}): IMockGetObject {
37+
return {
38+
// Instead of a built-in "createReadStream" method, we'll return a simple value in our mock
39+
createReadStream: mockedS3GetObject.mockReturnValue(Readable.from(['hello,name\n1,2\n'])),
40+
}
41+
}
42+
}
43+
})
44+
45+
// Similar to S3, we also need to mock DynamoDB DocumentClient
46+
jest.mock('aws-sdk/clients/dynamodb', () => {
47+
return { DocumentClient: class DocumentClient {
48+
// We use only the "put" method of the DynamoDB DocumentClient
49+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
50+
put(params: PutItemInput, cb: () => {}): jest.Mock {
51+
// The DynamoDB put method returns a promise function.
52+
// We'll simply return resolved promise from our mock,
53+
// as that's enough for our current unit tests
54+
return (mockedDynamoDBPut.mockReturnValue({
55+
promise: () => Promise.resolve(),
56+
}))(params, cb) // We also need to pass the arguments to our mock function
57+
}
58+
}}
59+
})
60+
61+
// We also need to mock the uuid module, because it returns random values, which makes it hard to test.
62+
// Other option would be to expect any string as an ID generated by the uuid module.
63+
jest.mock('uuid', () => {
64+
return {
65+
v4: jest.fn().mockReturnValue('123'),
66+
}
67+
})
68+
})
69+
70+
// After each test, we reset all mocks and reset the cache for all required node modules
71+
afterEach(() => {
72+
jest.resetAllMocks()
73+
jest.resetModules()
74+
})
75+
76+
test('should parse a single CSV file and store the row data into a database', async () => {
77+
// As we are using streams, we need to mock the fs module too.
78+
// We cand do this in the beforeEach section, but that creates some problems with writing to the finished writable stream.
79+
// Also, we need to modify the fs.createReadStream function for almost all tests, and it's easier to do that in the test itself.
80+
jest.mock('fs', () => {
81+
return {
82+
// The fs.createReadStream function returns a read stream with the test CSV data
83+
createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,2\n'])),
84+
// The fs.createWriteStream function returns an empty writabe stream, so we can write to it
85+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
86+
}
87+
})
88+
// We require and invoke our hadnler function.
89+
// Moving this function to the top of this file would break the mocks from the beforeEach section.
90+
const handler = require(lambdaPath).handler
91+
await handler(s3Event)
92+
// Then we test that all of our important mocks have been called.s
93+
expect(mockedS3GetObject).toHaveBeenCalled()
94+
expect(mockedDynamoDBPut).toHaveBeenCalled()
95+
expect(mockedDynamoDBPut).toHaveBeenCalledTimes(1)
96+
expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '2', id: '123' }, TableName: ''}, undefined)
97+
})
98+
99+
test('should parse a single CSV file with multiple rows and store the rows data into a database', async () => {
100+
jest.mock('fs', () => {
101+
return {
102+
// We simply return a different CSV file content here
103+
createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,1\n2,2\n'])),
104+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
105+
}
106+
})
107+
const handler = require(lambdaPath).handler
108+
await handler(s3Event)
109+
expect(mockedS3GetObject).toHaveBeenCalled()
110+
expect(mockedDynamoDBPut).toHaveBeenCalled()
111+
// And we test that both rows were saved to the DynamoDB table
112+
expect(mockedDynamoDBPut).toHaveBeenCalledTimes(2)
113+
// We test the arguments for the first invocation of our mockedDynamoDBPut
114+
expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '1', id: '123' }, TableName: '' }, undefined)
115+
// Then we do the same for the next one
116+
expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '2', name: '2', id: '123' }, TableName: '' }, undefined)
117+
})
118+
119+
test('should parse a single empty CSV file, without storing the CSV data into the database', async () => {
120+
jest.mock('fs', () => {
121+
return {
122+
// We send the empty CSV file in our fs.createReadStream mock
123+
createReadStream: jest.fn().mockImplementation(() => Readable.from([''])),
124+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
125+
}
126+
})
127+
const handler = require(lambdaPath).handler
128+
await handler(s3Event)
129+
expect(mockedS3GetObject).toHaveBeenCalled()
130+
expect(mockedDynamoDBPut).not.toHaveBeenCalled()
131+
})
132+
133+
test('should parse a single CSV file, with less row values than headers and store the ones properly parsed into the database', async () => {
134+
jest.mock('fs', () => {
135+
return {
136+
// We send less row values than headers in a CSV file in our fs.createReadStream mock
137+
createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name,ignored,headers\n1,2\n'])),
138+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
139+
}
140+
})
141+
const handler = require(lambdaPath).handler
142+
await handler(s3Event)
143+
expect(mockedS3GetObject).toHaveBeenCalled()
144+
expect(mockedDynamoDBPut).toHaveBeenCalled()
145+
expect(mockedDynamoDBPut).toHaveBeenCalledTimes(1)
146+
expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '2', id: '123' }, TableName: '' }, undefined)
147+
})
148+
149+
test('should parse multiple CSV files and store a JSON string', async () => {
150+
jest.mock('fs', () => {
151+
return {
152+
// We prepare a single row response from our
153+
createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name,\n1,2\n'])),
154+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
155+
}
156+
})
157+
// Copy the S3 event, because we don't want to to reference and edit the existing one
158+
const multipleFilesS3Event = Object.assign({}, s3Event)
159+
// Copy the single S3 event record, and then push multiple values of it
160+
// Don't worry, it will store them differently, because we are generating a different id for each CSV row
161+
const recordCopy = Object.assign({}, s3Event.Records[0])
162+
multipleFilesS3Event.Records.push(recordCopy)
163+
multipleFilesS3Event.Records.push(recordCopy)
164+
const handler = require(lambdaPath).handler
165+
await handler(multipleFilesS3Event)
166+
// Since there are three S3 files, the S3 getObject method needs to be called three times, to retrieve three documents
167+
expect(mockedS3GetObject).toHaveBeenCalledTimes(3)
168+
// Since there are three S3 files with a single row each, the DynamoDb put method needs to be called three times, to store the three rows
169+
expect(mockedDynamoDBPut).toHaveBeenCalledTimes(3)
170+
expect(mockedDynamoDBPut).toHaveBeenCalledWith({ Item: { hello: '1', name: '2', id: '123' }, TableName: '' }, undefined)
171+
})
172+
173+
test('should throw an error when the CSV file is unavailable from the bucket', async () => {
174+
// We define the error message for the S3 getObject operation, NoSuchKey Eception
175+
const fileUnavailableError = 'An error occurred (NoSuchKey) when calling the GetObject operation: The specified key does not exist.'
176+
jest.mock('fs', () => {
177+
return {
178+
createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,2\n'])),
179+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
180+
}
181+
})
182+
jest.mock('aws-sdk/clients/s3', () => {
183+
// Instead of a real class, we'll return a mock class here
184+
return class S3 {
185+
// We need to setup the "getObject" method to throw the defined Error when trying to stream
186+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
187+
getObject(_params: GetObjectRequest, _cb: () => {}): IMockGetObject {
188+
return {
189+
createReadStream: mockedS3GetObject.mockImplementation(() => { throw new Error(fileUnavailableError) }),
190+
}
191+
}
192+
}
193+
})
194+
195+
const handler = require(lambdaPath).handler
196+
await expect(handler(s3Event)).rejects.toEqual(new Error(fileUnavailableError))
197+
expect(mockedS3GetObject).toHaveBeenCalled()
198+
expect(mockedDynamoDBPut).not.toHaveBeenCalled()
199+
})
200+
201+
test('should throw an error if the parsed CSV data could not be stored into the database', async () => {
202+
// We define the error message for the DynamoDB operation, Access Denied Exception is a standard one
203+
// eslint-disable-next-line max-len
204+
const accessDeniedException = 'AccessDeniedException: User: arn:aws:sts::111111111:assumed-role/lambdaRole/username is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:us-east-1:111111111:table/covid19-cases/index/type-created_at-index'
205+
jest.mock('fs', () => {
206+
return {
207+
createReadStream: jest.fn().mockImplementation(() => Readable.from(['hello,name\n1,2\n'])),
208+
createWriteStream: jest.fn().mockImplementation(() => new Writable({ write: (_chunk, _encoding, done): void => done() })),
209+
}
210+
})
211+
// We also need to mock DynamoDB DocumentClient to properly throw this error
212+
jest.mock('aws-sdk/clients/dynamodb', () => {
213+
return { DocumentClient: class DocumentClient {
214+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
215+
put(params: PutItemInput, cb: () => {}): jest.Mock {
216+
// The DynamoDB put method returns a promise function.
217+
// We'll return a rejected promise from our mock, with an error message
218+
return (mockedDynamoDBPut.mockReturnValue({
219+
promise: () => Promise.reject(new Error(accessDeniedException)),
220+
}))(params, cb)
221+
}
222+
}}
223+
})
224+
const handler = require(lambdaPath).handler
225+
226+
await expect(handler(s3Event)).rejects.toEqual(new Error(accessDeniedException))
227+
expect(mockedS3GetObject).toHaveBeenCalled()
228+
expect(mockedDynamoDBPut).toHaveBeenCalled()
229+
})
230+
})

0 commit comments

Comments
 (0)