Selaa lähdekoodia

feat(test): Implement load test reporting

Relates to #74
Michael Bromley 6 vuotta sitten
vanhempi
sitoutus
dfce072afc

+ 2 - 0
packages/dev-server/.gitignore

@@ -4,3 +4,5 @@ test-emails
 vendure.sqlite
 vendure-import-error.log
 load-testing/data-sources/products*.csv
+load-testing/results/*.json
+load-testing/results/*.csv

+ 15 - 1
packages/dev-server/README.md

@@ -26,6 +26,20 @@ yarn populate --db=sqlite
 
 ## Load testing
 
-This package also contains scripts for load testing the Vendure server. The load testing infrastructure and scripts are located in the [./load-testing](./load-testing) directory.
+This package also contains scripts for load testing the Vendure server. The load testing infrastructure and scripts are located in the [`./load-testing`](./load-testing) directory.
 
 Load testing is done with [k6](https://docs.k6.io/), and to run them you will need k6 installed and (in Windows) available in your PATH environment variable so that it can be run with the command `k6`.
+
+The load tests assume the existence of the following tables in the MySQL database:
+
+* `vendure-load-testing-1000`
+* `vendure-load-testing-10000`
+* `vendure-load-testing-100000`
+
+The npm scripts `load-test:1k`, `load-test:10k` and `load-test:100k` will populate their respective databases with test data and then run the k6 scripts against them.
+
+### Results
+
+The results of the test are saved to the [`./load-testing/results`](./load-testing/results) directory.
+
+

+ 136 - 0
packages/dev-server/load-testing/generate-summary.ts

@@ -0,0 +1,136 @@
+import fs from 'fs';
+import path from 'path';
+import readline from 'readline';
+
+export type DataPoint = {
+    type: 'Point',
+    data: {
+        time: string;
+        value: number;
+    },
+    metric: string;
+};
+
+export type TimeSeriesPoint = { timestamp: number; value: number; };
+
+export type LoadTestSummary = {
+    timestamp: string;
+    script: string;
+    productCount: number;
+    testDuration: number;
+    requests: number;
+    throughput: number;
+    requestDurationSummary: {
+        avg: number;
+        min: number;
+        max: number;
+        med: number;
+        p90: number;
+        p95: number;
+        p99: number;
+    },
+    requestDurationTimeSeries: TimeSeriesPoint[];
+    concurrentUsersTimeSeries: TimeSeriesPoint[];
+};
+
+if (require.main === module) {
+    const resultsFile = process.argv[2];
+    generateSummary(resultsFile)
+        .then(result => {
+            // tslint:disable-next-line:no-console
+            console.log(JSON.stringify(result, null, 2));
+            process.exit(0);
+        });
+
+}
+
+/**
+ * Reads the raw JSON file output from k6 and parses it into a summary object.
+ */
+export async function generateSummary(rawResultsFile: string): Promise<LoadTestSummary> {
+    const lineReader = readline.createInterface({
+        input: fs.createReadStream(path.join(__dirname, 'results', rawResultsFile)),
+        crlfDelay: Infinity,
+    });
+    let reqs = 0;
+    let min = Infinity;
+    let max = 0;
+    let sum = 0;
+    let startTime = 0;
+    let endTime = 0;
+    const durations: number[] = [];
+    const requestDurationTimeSeries: TimeSeriesPoint[] = [];
+    const concurrentUsersTimeSeries: TimeSeriesPoint[] = [];
+
+    return new Promise((resolve, reject) => {
+        lineReader.on('line', line => {
+            const row = JSON.parse(line);
+            if (isDataPoint(row)) {
+                if (row.metric === 'http_reqs') {
+                    reqs++;
+                }
+                if (row.metric === 'http_req_duration') {
+                    const duration = row.data.value;
+                    durations.push(duration);
+                    requestDurationTimeSeries.push({ timestamp: +(new Date(row.data.time)), value: row.data.value });
+                    if (duration > max) {
+                        max = duration;
+                    }
+                    if (duration < min) {
+                        min = duration;
+                    }
+                    sum += duration;
+                }
+                if (row.metric === 'vus') {
+                    concurrentUsersTimeSeries.push({ timestamp: +(new Date(row.data.time)), value: row.data.value });
+                }
+                if (!startTime) {
+                    startTime = +(new Date(row.data.time));
+                }
+                endTime = +(new Date(row.data.time));
+            }
+        });
+
+        lineReader.on('close', () => {
+            const duration = (endTime - startTime) / 1000;
+            durations.sort((a, b) => a - b);
+
+            resolve({
+                timestamp: new Date().toISOString(),
+                script: rawResultsFile.split('.')[0],
+                productCount: +(rawResultsFile.split('.')[2]),
+                testDuration: duration,
+                requests: reqs,
+                throughput: reqs / duration,
+                requestDurationSummary: {
+                    avg: sum / reqs,
+                    min,
+                    max,
+                    med: durations[Math.round(durations.length / 2)],
+                    p90: percentile(90, durations),
+                    p95: percentile(95, durations),
+                    p99: percentile(99, durations),
+                },
+                requestDurationTimeSeries,
+                concurrentUsersTimeSeries,
+            });
+        });
+    });
+}
+
+function isDataPoint(row: any): row is DataPoint {
+    return row && row.type === 'Point';
+}
+
+function percentile(p: number, sortedValues: number[]): number {
+    const ordinalRank = ((p / 100) * sortedValues.length) - 1;
+    if (Number.isInteger(ordinalRank)) {
+        return sortedValues[ordinalRank];
+    }
+    // if the rank is not an integer, use linear interpolation between the
+    // surrounding values.
+    const j = sortedValues[Math.floor(ordinalRank)];
+    const k = sortedValues[Math.ceil(ordinalRank)];
+    const f = ordinalRank - Math.floor(ordinalRank);
+    return j + (k - j) * f;
+}

+ 11 - 4
packages/dev-server/load-testing/graphql/shop/add-to-order.graphql

@@ -1,6 +1,13 @@
 mutation ($id: ID! $qty: Int!) {
-  addItemToOrder(productVariantId: $id quantity: $qty) {
-    id
-    code
-  }
+    addItemToOrder(productVariantId: $id quantity: $qty) {
+        id
+        code
+        lines {
+            id
+            quantity
+            productVariant {
+                id
+            }
+        }
+    }
 }

+ 15 - 0
packages/dev-server/load-testing/graphql/shop/complete-order.graphql

@@ -0,0 +1,15 @@
+mutation SetShippingMethod($id: ID!) {
+    setOrderShippingMethod(shippingMethodId: $id) {
+        code
+    }
+    transitionOrderToState(state: "ArrangingPayment") {
+        code
+    }
+    addPaymentToOrder(input: {
+        method: "example-payment-provider",
+        metadata: {}
+    }) {
+        code
+        state
+    }
+}

+ 124 - 0
packages/dev-server/load-testing/graphql/shop/deep-query.graphql

@@ -0,0 +1,124 @@
+{
+    product(id: "1") {
+        assets {
+            fileSize
+            id
+            mimeType
+            name
+            preview
+            type
+        }
+        featuredAsset {
+            fileSize
+            id
+            mimeType
+            name
+            preview
+            type
+        }
+        collections {
+            assets {
+                id
+                preview
+            }
+            breadcrumbs {
+                id
+                name
+            }
+            children {
+                assets {
+                    id
+                    preview
+                }
+                breadcrumbs {
+                    id
+                    name
+                }
+
+            }
+            description
+            filters {
+                args {
+                    name
+                    type
+                    value
+                }
+                code
+                description
+            }
+            featuredAsset {
+                id
+                preview
+            }
+        }
+        description
+        facetValues {
+            code
+            facet {
+                code
+                id
+                name
+            }
+            name
+            id
+        }
+        optionGroups {
+            id
+            code
+            options {
+                id
+                code
+            }
+        }
+        variants {
+            assets {
+                id
+                preview
+            }
+            currencyCode
+            customFields
+            facetValues {
+                code
+                id
+                facet {
+                    code
+                    id
+                    name
+                }
+                name
+            }
+            featuredAsset {
+                id
+                preview
+            }
+            name
+            options {
+                id
+                code
+            }
+            price
+            priceIncludesTax
+            priceWithTax
+            sku
+            taxCategory {
+                id
+                name
+            }
+            taxRateApplied {
+                category {
+                    id
+                    name
+                }
+                customerGroup {
+                    id
+                    name
+                }
+                value
+                zone {
+                    id
+                    name
+                }
+            }
+        }
+    }
+}

+ 7 - 0
packages/dev-server/load-testing/graphql/shop/get-shipping-methods.graphql

@@ -0,0 +1,7 @@
+query GetEligibleShippingMethods {
+    eligibleShippingMethods {
+        id
+        description
+        price
+    }
+}

+ 74 - 0
packages/dev-server/load-testing/graphql/shop/set-shipping-address.graphql

@@ -0,0 +1,74 @@
+mutation SetShippingAddress($address: CreateAddressInput! $customer: CreateCustomerInput!) {
+    setOrderShippingAddress(input: $address) {
+        ...Cart
+        shippingAddress {
+            ...OrderAddress
+        }
+    }
+    setCustomerForOrder(input: $customer) {
+        id
+        customer {
+            id
+            emailAddress
+            firstName
+            lastName
+        }
+    }
+}
+
+fragment Cart on Order {
+    id
+    code
+    state
+    active
+    lines {
+        id
+        featuredAsset {
+            id
+            preview
+            name
+        }
+        unitPrice
+        unitPriceWithTax
+        quantity
+        totalPrice
+        productVariant {
+            id
+            name
+        }
+        adjustments {
+            amount
+            description
+            adjustmentSource
+            type
+        }
+    }
+    subTotal
+    subTotalBeforeTax
+    totalBeforeTax
+    shipping
+    shippingMethod {
+        id
+        code
+        description
+    }
+    total
+    adjustments {
+        amount
+        description
+        adjustmentSource
+        type
+    }
+}
+
+fragment OrderAddress on OrderAddress {
+    fullName
+    company
+    streetLine1
+    streetLine2
+    city
+    province
+    postalCode
+    country
+    phoneNumber
+}

+ 84 - 9
packages/dev-server/load-testing/init-load-test.ts

@@ -25,7 +25,7 @@ if (require.main === module) {
         .then(isPopulated => {
             if (!isPopulated) {
                 const count = getProductCount();
-                const config = getLoadTestConfig();
+                const config = getLoadTestConfig('bearer');
                 const csvFile = getProductCsvFilePath();
                 return clearAllTables(config.dbConnectionOptions, true)
                     .then(() => {
@@ -148,25 +148,19 @@ function generateMockData(productCount: number, writeFn: (row: string[]) => void
 
     writeFn(headers);
 
-    const LOREM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna ' +
-        'aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ' +
-        'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. ' +
-        'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'
-            .replace(/\r\n/g, '');
-
     const categories = getCategoryNames();
 
     for (let i = 1; i <= productCount; i++) {
         const outputRow: BaseProductRecord = {
             name: `Product ${i}`,
             slug: `product-${i}`,
-            description: LOREM,
+            description: generateProductDescription(),
             assets: 'product-image.jpg',
             facets: `category:${categories[i % categories.length]}`,
             optionGroups: '',
             optionValues: '',
             sku: `PRODID${i}`,
-            price: '12345',
+            price: (Math.random() * 1000).toFixed(2),
             taxCategory: 'standard',
             variantAssets: '',
             variantFacets: '',
@@ -179,3 +173,84 @@ function getCategoryNames() {
     const allNames = initialData.collections.reduce((all, c) => [...all, ...c.facetNames], [] as string[]);
     return Array.from(new Set(allNames));
 }
+
+const parts = [
+    `Now equipped with seventh-generation Intel Core processors`,
+    `Laptop is snappier than ever`,
+    `From daily tasks like launching apps and opening files to more advanced computing`,
+    `You can power through your day thanks to faster SSDs and Turbo Boost processing up to 3.6GHz`,
+    `Discover a truly immersive viewing experience with this monitor curved more deeply than any other`,
+    `Wrapping around your field of vision the 1,800 R screencreates a wider field of view`,
+    `This pc is optimised for gaming, and is also VR ready`,
+    `The Intel Core-i7 CPU and High Performance GPU give the computer the raw power it needs to function at a high level`,
+    `Boost your PC storage with this internal hard drive, designed just for desktop and all-in-one PCs`,
+    `Let all your colleagues know that you are typing on this exclusive, colorful klicky-klacky keyboard`,
+    `Solid conductors eliminate strand-interaction distortion and reduce jitter`,
+    `As the surface is made of high-purity silver`,
+    `the performance is very close to that of a solid silver cable`,
+    `but priced much closer to solid copper cable`,
+    `With its nostalgic design and simple point-and-shoot functionality`,
+    `the Instant Camera is the perfect pick to get started with instant photography`,
+    `This lens is a Di type lens using an optical system with improved multi-coating designed to function with digital SLR cameras as well as film cameras`,
+    `Capture vivid, professional-style photographs with help from this lightweight tripod`,
+    `The adjustable-height tripod makes it easy to achieve reliable stability`,
+    `Just the right angle when going after that award-winning shot`,
+    `Featuring a full carbon chassis - complete with cyclocross-specific carbon fork`,
+    `It's got the low weight, exceptional efficiency and brilliant handling`,
+    `You'll need to stay at the front of the pack`,
+    `When you're working out you need a quality rope that doesn't tangle at every couple of jumps`,
+    `Training gloves designed for optimum training`,
+    `Our gloves promote proper punching technique because they are conformed to the natural shape of your fist`,
+    `Dense, innovative two-layer foam provides better shock absorbency`,
+    `Full padding on the front, back and wrist to promote proper punching technique`,
+    `With tons of space inside (for max. 4 persons), full head height throughout`,
+    `This tent offers you everything you need`,
+    `Based on the 1970s iconic shape, but made to a larger 69cm size`,
+    `These skateboards are great for beginners to learn the foot spacing required`,
+    `Perfect for all-day cruising`,
+    `This football features high-contrast graphics for high-visibility during play`,
+    `Its machine-stitched tpu casing offers consistent performance`,
+    `With its ultra-light, uber-responsive magic foam`,
+    `The Running Shoe is ready to push you to victories both large and small`,
+    `A spiky yet elegant house cactus`,
+    `Perfect for the home or office`,
+    `Origin and habitat: Probably native only to the Andes of Peru`,
+    `Gloriously elegant`,
+    `It can go along with any interior as it is a neutral color and the most popular Phalaenopsis overall`,
+    `2 to 3 foot stems host large white flowers that can last for over 2 months`,
+    `Excellent semi-evergreen bonsai`,
+    `Indoors or out but needs some winter protection`,
+    `All trees sent will leave the nursery in excellent condition and will be of equal quality or better than the photograph shown`,
+    `Placing it at home or office can bring you fortune and prosperity`,
+    `Guards your house and ward off ill fortune`,
+    `Hand trowel for garden cultivating hammer finish epoxy-coated head`,
+    `For improved resistance to rust, scratches, humidity and alkalines in the soil`,
+    `A charming vintage white wooden chair`,
+    `Featuring an extremely spherical pink balloon`,
+    `The balloon may be detached and used for other purposes`,
+    `This premium, tan-brown bonded leather seat is part of the 'chill' sofa range`,
+    `The lever activated recline feature makes it easy to adjust to any position`,
+    `This smart, bustle back design with rounded tight padded arms has been designed with your comfort in mind`,
+    `This well-padded chair has foam pocket sprung seat cushions and fibre-filled back cushions`,
+    `Modern tapered white polycotton pendant shade with a metallic silver chrome interior`,
+    `For maximum light reflection`,
+    `Reversible gimble so it can be used as a ceiling shade or as a lamp shade`,
+];
+function generateProductDescription(): string {
+    const take = Math.ceil(Math.random() * 4);
+    return shuffle(parts).slice(0, take)
+        .join('. ');
+}
+
+/**
+ * Returns new copy of array in random order.
+ * https://stackoverflow.com/a/6274381/772859
+ */
+function shuffle<T>(arr: T[]): T[] {
+    const a = arr.slice();
+    for (let i = a.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        [a[i], a[j]] = [a[j], a[i]];
+    }
+    return a;
+}

+ 2 - 2
packages/dev-server/load-testing/load-test-config.ts

@@ -15,13 +15,13 @@ export function getMysqlConnectionOptions(count: number) {
     };
 }
 
-export function getLoadTestConfig(): VendureConfig {
+export function getLoadTestConfig(tokenMethod: 'cookie' | 'bearer'): VendureConfig {
     const count = getProductCount();
     return {
         ...devConfig as any,
         dbConnectionOptions: getMysqlConnectionOptions(count),
         authOptions: {
-            tokenMethod: 'bearer',
+            tokenMethod,
             requireVerification: false,
         },
         importExportOptions: {

+ 0 - 0
packages/dev-server/load-testing/results/.gitkeep


+ 131 - 26
packages/dev-server/load-testing/run-load-test.ts

@@ -1,37 +1,142 @@
 /* tslint:disable:no-console */
+import { INestApplication } from '@nestjs/common';
 import { bootstrap } from '@vendure/core';
-import { ChildProcess, spawn } from 'child_process';
+import { spawn } from 'child_process';
+import stringify from 'csv-stringify';
+import fs from 'fs';
+import path from 'path';
 
+import { omit } from '../../common/src/omit';
+
+import { generateSummary, LoadTestSummary } from './generate-summary';
 import { getLoadTestConfig, getProductCount } from './load-test-config';
 
 const count = getProductCount();
 
-console.log(`\n============= Vendure Load Test: ${count} products ============\n`);
+if (require.main === module) {
+    console.log(`\n============= Vendure Load Test: ${count} products ============\n`);
 
 // Runs the init script to generate test data and populate the test database
-const init = spawn('node', ['-r', 'ts-node/register', './init-load-test.ts', count.toString()], {
-    cwd: __dirname,
-    stdio: 'inherit',
-});
-
-init.on('exit', code => {
-    if (code === 0) {
-        return bootstrap(getLoadTestConfig())
-            .then(app => {
-                const loadTest = spawn('k6', ['run', './scripts/search-and-checkout.js'], {
-                    cwd: __dirname,
-                    stdio: 'inherit',
-                });
-                loadTest.on('exit', () => {
-                    app.close();
-                    process.exit(0);
+    const init = spawn('node', ['-r', 'ts-node/register', './init-load-test.ts', count.toString()], {
+        cwd: __dirname,
+        stdio: 'inherit',
+    });
+
+    init.on('exit', code => {
+        if (code === 0) {
+            return bootstrap(getLoadTestConfig('cookie'))
+                .then(app => {
+                    return runLoadTestScript('deep-query.js')
+                        .then((summary1) => runLoadTestScript('search-and-checkout.js').then(summary2 => [summary1, summary2]))
+                        .then(summaries => {
+                            closeAndExit(app, summaries);
+                        });
+                })
+                .catch(err => {
+                    // tslint:disable-next-line
+                    console.log(err);
                 });
-            })
-            .catch(err => {
-                // tslint:disable-next-line
-                console.log(err);
-            });
-    } else {
-        process.exit(code || 1);
+        } else {
+            process.exit(code || 1);
+        }
+    });
+}
+
+function runLoadTestScript(script: string): Promise<LoadTestSummary> {
+    const rawResultsFile = `${script}.${count}.json`;
+
+    return new Promise((resolve, reject) => {
+        const loadTest = spawn('k6', ['run', `./scripts/${script}`, '--out', `json=results/${rawResultsFile}`], {
+            cwd: __dirname,
+            stdio: 'inherit',
+        });
+        loadTest.on('exit', code => {
+            if (code === 0) {
+                resolve(code);
+            } else {
+                reject();
+            }
+        });
+        loadTest.on('error', err => {
+            reject(err);
+        });
+    })
+        .then(() => generateSummary(rawResultsFile));
+}
+
+async function closeAndExit(app: INestApplication, summaries: LoadTestSummary[]) {
+    console.log('Closing server and preparing results...');
+    // allow a pause for all queries to complete before closing the app
+    await new Promise(resolve => setTimeout(resolve, 3000));
+    await app.close();
+    const dateString = getDateString();
+
+    // write summary JSON
+    const summaryData = summaries.map(s => omit(s, ['requestDurationTimeSeries', 'concurrentUsersTimeSeries']));
+    const summaryFile = path.join(__dirname, `results/load-test-${dateString}-${count}.json`);
+    fs.writeFileSync(summaryFile, JSON.stringify(summaryData, null, 2), 'utf-8');
+    console.log(`Summary written to ${path.relative(__dirname, summaryFile)}`);
+
+    // write time series CSV
+    for (const summary of summaries) {
+        const csvData = await getTimeSeriesCsvData(summary);
+        const timeSeriesFile = path.join(__dirname, `results/load-test-${dateString}-${count}-${summary.script}.csv`);
+        fs.writeFileSync(timeSeriesFile, csvData, 'utf-8');
+        console.log(`Time series data written to ${path.relative(__dirname, timeSeriesFile)}`);
     }
-});
+
+    process.exit(0);
+}
+
+async function getTimeSeriesCsvData(summary: LoadTestSummary): Promise<string> {
+
+    const stringifier = stringify({
+        delimiter: ',',
+    });
+
+    const data: string[] = [];
+
+    stringifier.on('readable', () => {
+        let row;
+        // tslint:disable-next-line:no-conditional-assignment
+        while (row = stringifier.read()) {
+            data.push(row);
+        }
+    });
+
+    stringifier.write([
+        `${summary.script}:elapsed`,
+        `${summary.script}:request_duration`,
+        `${summary.script}:user_count`,
+    ]);
+
+    let startTime: number | undefined;
+
+    for (const row of summary.requestDurationTimeSeries) {
+        if (!startTime) {
+            startTime = row.timestamp;
+        }
+        stringifier.write([row.timestamp - startTime, row.value, '']);
+    }
+    for (const row of summary.concurrentUsersTimeSeries) {
+        if (!startTime) {
+            startTime = row.timestamp;
+        }
+        stringifier.write([row.timestamp - startTime, '', row.value]);
+    }
+
+    stringifier.end();
+
+    return new Promise((resolve, reject) => {
+        stringifier.on('error', (err: any) => {
+            reject(err.message);
+        });
+        stringifier.on('finish', async () => {
+            resolve(data.join(''));
+        });
+    });
+}
+
+function getDateString(): string {
+    return (new Date().toISOString()).split('.')[0].replace(/[:\.]/g, '_');
+}

+ 16 - 0
packages/dev-server/load-testing/scripts/deep-query.js

@@ -0,0 +1,16 @@
+import { ShopApiRequest } from '../utils/api-request.js';
+
+const deepQuery = new ShopApiRequest('shop/deep-query.graphql');
+
+export let options = {
+    stages: [
+        { duration: '1m', target: 500 },
+    ],
+};
+
+/**
+ * Performs a single deeply-nested GraphQL query
+ */
+export default function() {
+    deepQuery.post();
+}

+ 51 - 23
packages/dev-server/load-testing/scripts/search-and-checkout.js

@@ -1,52 +1,80 @@
 // @ts-check
 import {sleep} from 'k6';
+import { check } from 'k6';
 import {ShopApiRequest} from '../utils/api-request.js';
 
 const searchQuery = new ShopApiRequest('shop/search.graphql');
 const productQuery = new ShopApiRequest('shop/product.graphql');
 const addItemToOrderMutation = new ShopApiRequest('shop/add-to-order.graphql');
+const setShippingAddressMutation = new ShopApiRequest('shop/set-shipping-address.graphql');
+const getShippingMethodsQuery = new ShopApiRequest('shop/get-shipping-methods.graphql');
+const completeOrderMutation = new ShopApiRequest('shop/complete-order.graphql');
 
 export let options = {
-  stages: [
-      { duration: '30s', target: 10 },
-      { duration: '1m', target: 75 },
-      { duration: '1m', target: 150 },
-      { duration: '1m', target: 0 },
-  ],
+    stages: [
+        { duration: '4m', target: 500 },
+    ],
 };
 
 /**
  * Searches for products, adds to order, checks out.
  */
 export default function() {
-  const itemsToAdd = Math.ceil(Math.random() * 10);
+    const itemsToAdd = Math.ceil(Math.random() * 10);
 
-  for (let i = 0; i < itemsToAdd; i ++) {
-    searchProducts();
-    const product = findAndLoadProduct();
-    addToCart(randomItem(product.variants).id);
-  }
+    for (let i = 0; i < itemsToAdd; i ++) {
+        searchProducts();
+        const product = findAndLoadProduct();
+        addToCart(randomItem(product.variants).id);
+    }
+    setShippingAddressAndCustomer();
+    const data = getShippingMethodsQuery.post().data;
+    const result = completeOrderMutation.post({ id: data.eligibleShippingMethods[0].id }).data;
+    check(result, {
+        'Order completed': r => r.addPaymentToOrder.state === 'PaymentSettled',
+    });
 }
 
 function searchProducts() {
-  for (let i = 0; i < 4; i++) {
-    searchQuery.post();
-    sleep(Math.random() * 3 + 0.5);
-  }
+    for (let i = 0; i < 4; i++) {
+        searchQuery.post();
+        sleep(Math.random() * 3 + 0.5);
+    }
 }
 
 function findAndLoadProduct() {
-  const searchResult = searchQuery.post();
-  const items = searchResult.data.search.items;
-  const productResult = productQuery.post({ id: randomItem(items).productId });
-  return productResult.data.product;
+    const searchResult = searchQuery.post();
+    const items = searchResult.data.search.items;
+    const productResult = productQuery.post({ id: randomItem(items).productId });
+    return productResult.data.product;
 }
 
 function addToCart(variantId) {
-  const qty = Math.ceil(Math.random() * 4);
-  addItemToOrderMutation.post({ id: variantId, qty });
+    const qty = Math.ceil(Math.random() * 4);
+    const result = addItemToOrderMutation.post({ id: variantId, qty });
+    check(result.data, {
+        'Product added to cart': r => !!r.addItemToOrder.lines
+            .find(l => l.productVariant.id === variantId && l.quantity >= qty),
+    });
+}
+
+function setShippingAddressAndCustomer() {
+    const result = setShippingAddressMutation.post({
+        address: {
+            countryCode: 'GB',
+            streetLine1: '123 Test Street',
+        },
+        customer: {
+            emailAddress: `test-user-${Math.random().toString(32).substr(3)}@mail.com`,
+            firstName: `Test`,
+            lastName: `User`,
+        },
+    });
+    check(result.data, {
+        'Address set': r => r.setOrderShippingAddress.shippingAddress.country === 'United Kingdom',
+    });
 }
 
 function randomItem(items) {
-  return items[Math.floor(Math.random() * items.length)];
+    return items[Math.floor(Math.random() * items.length)];
 }

+ 5 - 0
packages/dev-server/load-testing/utils/api-request.js

@@ -7,10 +7,15 @@ export class ShopApiRequest {
         this.document = open('../graphql/' + fileName);
     }
 
+    /**
+     * Post the GraphQL request
+     */
     post(variables = {}) {
         const res = http.post('http://localhost:3000/shop-api/', {
             query: this.document,
             variables: JSON.stringify(variables),
+        }, {
+            timeout: 120 * 1000,
         });
         check(res, {
             'Did not error': r => r.json().errors == null && r.status === 200,

+ 2 - 1
packages/dev-server/package.json

@@ -8,7 +8,8 @@
     "populate": "node -r ts-node/register populate-dev-server.ts",
     "start": "nodemon --config nodemon-debug.json index.ts",
     "load-test:1k": "node -r ts-node/register load-testing/run-load-test.ts 1000",
-    "load-test:10k": "node -r ts-node/register load-testing/run-load-test.ts 10000"
+    "load-test:10k": "node -r ts-node/register load-testing/run-load-test.ts 10000",
+    "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
     "@vendure/admin-ui-plugin": "~0.1.0",