Skip to content

Commit

Permalink
feat: dynamic test data generation with faker.js (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
ewingjm committed Oct 25, 2020
1 parent 693ac9d commit f3d9e40
Show file tree
Hide file tree
Showing 14 changed files with 278 additions and 32 deletions.
53 changes: 31 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ The aim of this project is to make Power Apps test automation easier, faster and

## Table of Contents

1. [Installation](#Installation)
1. [Usage](#Usage)
1. [Contributing](#Contributing)
1. [Credits](#Credits)
1. [Licence](#Licence)
- [Installation](#Installation)
- [Usage](#Usage)
- [Configuration](#Configuration)
- [Writing feature files](#Writing-feature-files)
- [Writing step bindings](#Writing-step-bindings)
- [Test setup](#Test-setup)
- [Contributing](#Contributing)
- [Licence](#Licence)

## Installation

Expand Down Expand Up @@ -94,17 +97,15 @@ public class MyCustomSteps : PowerAppsStepDefiner

### Test setup

We are avoiding performing test setup via the UI. This speeds up test execution and makes the tests more robust (as UI automation is more fragile than using supported APIs). _Given_ steps should therefore be carried out using the [Client API](client-api), [WebAPI](web-api) or [Organization Service](org-service).
We avoid performing test setup via the UI. This speeds up test execution and makes the tests more robust (as UI automation is more fragile than using supported APIs). _Given_ steps should therefore be carried out using the [Client API](client-api), [WebAPI](web-api) or [Organization Service](org-service).

You can create test data by using the following _Given_ step -

```gherkin
Given I have created "a record"
```

It will look for a JSON file in the _data_ folder. You must ensure that these files are copying to the build output directory. You do not need to include the .json extension when writing the step (the example above would look for _'a record.json'_).

The JSON is the same as expected by WebAPI when creating records via a [deep insert](https://docs.microsoft.com/en-us/dynamics365/customer-engagement/developer/webapi/create-entity-web-api#create-related-entities-in-one-operation). The example below will create the following -
This will look for a JSON file named _a record.json_ in the _data_ folder (you must ensure that these files are copying to the build output directory). The JSON is the same as expected by WebAPI when creating records via a [deep insert](https://docs.microsoft.com/en-us/dynamics365/customer-engagement/developer/webapi/create-entity-web-api#create-related-entities-in-one-operation). The example below will create the following -

- An account
- An primary contact related to the account
Expand All @@ -130,20 +131,9 @@ The JSON is the same as expected by WebAPI when creating records via a [deep ins
```

The `@logicalName` property is required for the root record.
`@alias` property can optionally be added to any record and allows the record to be referenced in certain bindings including the json for subsequent data creation steps per the example below:

Step 1 Given I have created "a contact"

```json
{
"@logicalName": "contact",
"@alias": "sample contact",
"firstname": "John",
"lastname": "Smith"
}
The `@alias` property can optionally be added to any record and allows the record to be referenced in certain bindings. The _Given I have created_ binding itself supports relating records using `@alias.bind` syntax.

```
Step 2 Given I have created "a account"
```json
{
"@logicalName": "account",
Expand All @@ -153,9 +143,28 @@ Step 2 Given I have created "a account"
}
```

We also support the use of
[faker.js](https://github.com/marak/Faker.js) moustache template syntax for generating dynamic test data at run-time. Please refer to the faker documentation for all of the functionality that is available.

The below JSON will generate a contact with a random name, credit limit, email address, and date of birth in the past 90 years:

```json
{
"@logicalName": "contact",
"@alias": "a dynamically generated contact",
"lastname": "{{name.firstName}}",
"firstname": "{{name.lastName}}",
"creditlimit@faker.number": "{{finance.amount}}",
"emailaddress1": "{{internet.email}}",
"birthdate@faker.date": "{{date.past(90)}}"
}
```

When using faker syntax, you must also annotate number or date fields using `@faker.number`, `@faker.date` or `@faker.dateonly` to ensure that the JSON is formatted correctly.

## Contributing

Ensure that your changes are thoroughly tested before creating a pull request. If applicable, update the UI test project within the tests folder to ensure coverage for your changes.
Please refer to the [Contributing](./CONTRIBUTING.md) guide.

## Licence

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ private void Initialise()
$@"var recordRepository = new {LibraryNamespace}.RecordRepository(Xrm.WebApi.online);
var metadataRepository = new {LibraryNamespace}.MetadataRepository(Xrm.WebApi.online);
var deepInsertService = new {LibraryNamespace}.DeepInsertService(metadataRepository, recordRepository);
var dataManager = new {LibraryNamespace}.DataManager(recordRepository, deepInsertService);
var dataManager = new {LibraryNamespace}.DataManager(recordRepository, deepInsertService, [new {LibraryNamespace}.FakerPreprocessor()]);
{TestDriverReference} = new {LibraryNamespace}.Driver(dataManager);");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="DataSteps.feature" />
<None Include="Data\data decorated with faker moustache syntax.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="Data\an account with aliased contact.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"@logicalName": "contact",
"@alias": "the faked record",
"lastname": "{{name.firstName}}",
"firstname": "{{name.lastName}}",
"creditlimit@faker.number": "{{finance.amount}}",
"emailaddress1": "{{internet.email}}",
"birthdate@faker.date": "{{date.past(90)}}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
As a developer
I want to use pre-existing data creation steps

Scenario: Create a contact with an alias then create an account referencing the alias as the primary contact id
Scenario: Create a record with an alias then create another record referencing the alias with @alias.bind
Given I am logged in to the 'Sales Team Member' app as 'an admin'
And I have created 'an aliased contact'
And I have created 'an account with aliased contact'
And I have opened 'a sample account'
And I have opened 'a sample account'

Scenario: Use faker.js syntax to generate data values at run-time
Given I am logged in to the 'Customer Service Hub' app as 'an admin'
And I have created 'data decorated with faker moustache syntax'
And I have opened 'the faked record'
11 changes: 11 additions & 0 deletions driver/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions driver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"scripts": {
"clean": "if exist dist rd dist /s /q",
"lint": "npx eslint . --fix -o test_results/analysis/eslint.json -f json",
"build": "npm run lint && webpack --config webpack.config.js",
"build": "npm install && npm run lint && webpack --config webpack.config.js",
"build:watch": "webpack --config webpack.config.js --watch",
"test": "karma start karma.conf.js",
"test:ci": "karma start karma.conf.js --single-run"
Expand All @@ -11,6 +11,7 @@
"dist"
],
"devDependencies": {
"@types/faker": "^5.1.3",
"@types/jasmine": "^3.5.10",
"@types/xrm": "^9.0.27",
"@typescript-eslint/eslint-plugin": "^4.2.0",
Expand All @@ -34,5 +35,7 @@
"webpack": "^4.44.2",
"webpack-cli": "^4.0.0"
},
"dependencies": {}
"dependencies": {
"faker": "^5.1.0"
}
}
25 changes: 22 additions & 3 deletions driver/src/data/dataManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RecordRepository } from '../repositories';
import DeepInsertService from './deepInsertService';
import Preprocessor from './preprocessor';
import { Record } from './record';

/**
Expand All @@ -17,17 +18,24 @@ export default class DataManager {

private readonly deepInsertSvc: DeepInsertService;

private readonly preprocessors?: Preprocessor[];

/**
* Creates an instance of DataManager.
* @param {RecordRepository} recordRepository A record repository.
* @param {DeepInsertService} deepInsertService A deep insert parser.
* @memberof DataManager
*/
constructor(recordRepository: RecordRepository, deepInsertService: DeepInsertService) {
constructor(
recordRepository: RecordRepository,
deepInsertService: DeepInsertService,
preprocessors?: Preprocessor[],
) {
this.refs = [];
this.refsByAlias = {};
this.recordRepo = recordRepository;
this.deepInsertSvc = deepInsertService;
this.preprocessors = preprocessors;
}

/**
Expand All @@ -38,9 +46,13 @@ export default class DataManager {
* @memberof DataManager
*/
public async createData(logicalName: string, record: Record): Promise<Xrm.LookupValue> {
const res = await this.deepInsertSvc.deepInsert(logicalName, record, this.refsByAlias);
const newRecords = [res.record, ...res.associatedRecords];
const res = await this.deepInsertSvc.deepInsert(
logicalName,
this.preprocess(record),
this.refsByAlias,
);

const newRecords = [res.record, ...res.associatedRecords];
this.refs.push(...newRecords.map((r) => r.reference));
newRecords
.filter((r) => r.alias !== undefined)
Expand Down Expand Up @@ -77,4 +89,11 @@ export default class DataManager {
Object.keys(this.refsByAlias).forEach((alias) => delete this.refsByAlias[alias]);
return Promise.all(deletePromises);
}

private preprocess(data: Record): Record {
return this.preprocessors?.reduce<Record>(
(record, preprocesser) => preprocesser.preprocess(record) ?? record,
data,
) ?? data;
}
}
7 changes: 6 additions & 1 deletion driver/src/data/deepInsertService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@ export default class DeepInsertService {
private static getManyToOneRecords(record: Record)
: { [navigationProperty: string]: Record } {
return Object.keys(record)
.filter((key) => typeof record[key] === 'object' && !Array.isArray(record[key]))
.filter(
(key) => typeof record[key] === 'object'
&& !Array.isArray(record[key])
&& record[key] !== null
&& !(record[key] instanceof Date),
)
.reduce((prev, curr) => {
// eslint-disable-next-line no-param-reassign
prev[curr] = record[curr] as Record;
Expand Down
69 changes: 69 additions & 0 deletions driver/src/data/fakerPreprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as faker from 'faker';
import Preprocessor from './preprocessor';
import { Record } from './record';

export default class FakerPreprocessor extends Preprocessor {
// eslint-disable-next-line class-methods-use-this
preprocess(data: Record): Record {
return FakerPreprocessor.parse(FakerPreprocessor.fake(data));
}

private static fake(data: Record) : string {
return faker.fake(JSON.stringify(data));
}

private static parse(data: string) : Record {
const cleansedData = JSON.parse(data);

Object.keys(cleansedData).forEach((property) => {
const val = cleansedData[property];

if (property.endsWith('@faker.number') && typeof val === 'string') {
cleansedData[property.replace('@faker.number', '')] = this.parseNumber(val, property);
delete cleansedData[property];
}

if (property.endsWith('@faker.datetime') && typeof val === 'string') {
cleansedData[property.replace('@faker.datetime', '')] = this.parseDate(val, property, false);
delete cleansedData[property];
}

if (property.endsWith('@faker.date') && typeof val === 'string') {
cleansedData[property.replace('@faker.date', '')] = this.parseDate(val, property, true);
delete cleansedData[property];
}

if (Array.isArray(val)) {
cleansedData[property] = val.map(
(collectionRecord) => this.parse(JSON.stringify(collectionRecord)),
);
}

if (typeof val === 'object' && val !== null) {
cleansedData[property] = this.parse(JSON.stringify(val));
}
});

return cleansedData;
}

private static parseNumber(val: string, property: string) {
const num = +val;
if (Number.isNaN(num)) {
throw new Error(`@faker.number syntax failed to convert ${property} with value ${val} to a number.`);
}

return num;
}

private static parseDate(val: string, property: string, dateOnly: boolean) {
const parsedDate = Date.parse(val);
if (Number.isNaN(parsedDate)) {
throw new Error(`@faker.datetime syntax failed to convert ${property} with value ${val} to a date.`);
}

const date = new Date(parsedDate);

return dateOnly ? date.toISOString().substring(0, 10) : date;
}
}
2 changes: 2 additions & 0 deletions driver/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export { default as DeepInsertService } from './deepInsertService';
export { DeepInsertResponse } from './deepInsertResponse';
export { Record } from './record';
export { TestRecord } from './testRecord';
export { default as Preprocessor } from './preprocessor';
export { default as FakerPreprocessor } from './fakerPreprocessor';
5 changes: 5 additions & 0 deletions driver/src/data/preprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Record } from './record';

export default abstract class Preprocessor {
abstract preprocess(data: Record): Record;
}
2 changes: 1 addition & 1 deletion driver/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { default as Driver } from './driver';
export { DataManager, DeepInsertService } from './data';
export { DataManager, DeepInsertService, FakerPreprocessor } from './data';
export { RecordRepository, MetadataRepository } from './repositories';
Loading

0 comments on commit f3d9e40

Please sign in to comment.