Skip to main content

10 - Testing & Simulation

Sensor simulation, testing strategies, and quality assurance


Table of Contents

  1. Testing Strategy
  2. Sensor Simulator
  3. Integration Tests
  4. Load Testing
  5. End-to-End Testing

1. Testing Strategy

Test Pyramid

                    ┌─────────────┐
│ E2E │ ← Few, slow, expensive
│ Tests │
├─────────────┤
│ Integration │ ← Medium amount
│ Tests │
├─────────────┤
│ Unit │ ← Many, fast, cheap
│ Tests │
└─────────────┘

Test Coverage Goals

LayerCoverageTools
Unit Tests80%Jest, Vitest
Integration Tests60%Supertest, Testcontainers
E2E TestsCritical pathsPlaywright, Cypress
Load TestsKey endpointsk6, Artillery

2. Sensor Simulator

2.1 Node.js Simulator

// src/lib/testing/sensor-simulator.ts

import mqtt from "mqtt";

interface SimulatorConfig {
brokerUrl: string;
plantId: string;
sensors: SensorConfig[];
intervalMs: number;
}

interface SensorConfig {
id: string;
parameter: string;
unit: string;
baseValue: number;
variance: number;
anomalyChance: number;
}

export class SensorSimulator {
private client: mqtt.MqttClient | null = null;
private config: SimulatorConfig;
private interval: NodeJS.Timeout | null = null;
private running = false;

constructor(config: SimulatorConfig) {
this.config = config;
}

async start() {
this.client = mqtt.connect(this.config.brokerUrl);

await new Promise<void>((resolve, reject) => {
this.client!.on("connect", () => {
console.log("Simulator connected to MQTT broker");
resolve();
});
this.client!.on("error", reject);
});

this.running = true;
this.interval = setInterval(() => this.publishReadings(), this.config.intervalMs);
console.log(`Simulator started - publishing every ${this.config.intervalMs}ms`);
}

private publishReadings() {
if (!this.client || !this.running) return;

for (const sensor of this.config.sensors) {
const reading = this.generateReading(sensor);
const topic = `sensors/${this.config.plantId}/${sensor.id}/${sensor.parameter}`;

this.client.publish(topic, JSON.stringify(reading), { qos: 1 });
}
}

private generateReading(sensor: SensorConfig) {
let value = sensor.baseValue;

// Add random variance
const variance = (Math.random() - 0.5) * 2 * sensor.variance;
value += variance;

// Occasionally inject anomaly
if (Math.random() < sensor.anomalyChance) {
value *= Math.random() > 0.5 ? 1.5 : 0.5;
console.log(`⚠️ Anomaly injected for ${sensor.parameter}: ${value.toFixed(2)}`);
}

// Ensure value is within reasonable bounds
value = Math.max(0, value);

return {
sensorId: sensor.id,
parameter: sensor.parameter,
value: parseFloat(value.toFixed(2)),
unit: sensor.unit,
timestamp: new Date().toISOString(),
quality: "good",
metadata: {
plantId: this.config.plantId,
simulated: true,
},
};
}

stop() {
this.running = false;
if (this.interval) {
clearInterval(this.interval);
}
if (this.client) {
this.client.end();
}
console.log("Simulator stopped");
}

// Inject specific anomaly
injectAnomaly(sensorId: string, value: number) {
if (!this.client) return;

const sensor = this.config.sensors.find((s) => s.id === sensorId);
if (!sensor) return;

const topic = `sensors/${this.config.plantId}/${sensorId}/${sensor.parameter}`;
const reading = {
sensorId,
parameter: sensor.parameter,
value,
unit: sensor.unit,
timestamp: new Date().toISOString(),
quality: "good",
metadata: {
plantId: this.config.plantId,
simulated: true,
anomaly: true,
},
};

this.client.publish(topic, JSON.stringify(reading), { qos: 1 });
console.log(`🚨 Manual anomaly injected: ${sensor.parameter} = ${value}`);
}
}

// Default configuration for water treatment plant
export const defaultSimulatorConfig: SimulatorConfig = {
brokerUrl: process.env.MQTT_BROKER_URL || "mqtt://localhost:1883",
plantId: "test-plant-01",
intervalMs: 1000,
sensors: [
{ id: "pH_001", parameter: "pH", unit: "pH", baseValue: 7.2, variance: 0.3, anomalyChance: 0.02 },
{ id: "BOD_001", parameter: "BOD", unit: "mg/L", baseValue: 25, variance: 5, anomalyChance: 0.02 },
{ id: "COD_001", parameter: "COD", unit: "mg/L", baseValue: 180, variance: 30, anomalyChance: 0.02 },
{ id: "TSS_001", parameter: "TSS", unit: "mg/L", baseValue: 80, variance: 15, anomalyChance: 0.02 },
{ id: "TDS_001", parameter: "TDS", unit: "mg/L", baseValue: 500, variance: 50, anomalyChance: 0.02 },
{ id: "FLOW_001", parameter: "flow_rate", unit: "m³/hr", baseValue: 150, variance: 20, anomalyChance: 0.01 },
{ id: "TEMP_001", parameter: "temperature", unit: "°C", baseValue: 25, variance: 2, anomalyChance: 0.01 },
],
};

// CLI runner
if (require.main === module) {
const simulator = new SensorSimulator(defaultSimulatorConfig);

simulator.start().then(() => {
console.log("Press Ctrl+C to stop");

// Inject anomaly every 30 seconds for testing
setInterval(() => {
const sensors = defaultSimulatorConfig.sensors;
const randomSensor = sensors[Math.floor(Math.random() * sensors.length)];
simulator.injectAnomaly(randomSensor.id, randomSensor.baseValue * 2);
}, 30000);
});

process.on("SIGINT", () => {
simulator.stop();
process.exit(0);
});
}

2.2 Run Simulator

# Start simulator
npx ts-node src/lib/testing/sensor-simulator.ts

# Or with custom config
MQTT_BROKER_URL=mqtt://your-broker:1883 npx ts-node src/lib/testing/sensor-simulator.ts

2.3 HTTP Simulator (No MQTT)

// src/lib/testing/http-simulator.ts

export class HTTPSensorSimulator {
private config: SimulatorConfig;
private interval: NodeJS.Timeout | null = null;
private apiUrl: string;
private apiKey: string;

constructor(config: SimulatorConfig, apiUrl: string, apiKey: string) {
this.config = config;
this.apiUrl = apiUrl;
this.apiKey = apiKey;
}

async start() {
this.interval = setInterval(() => this.sendReadings(), this.config.intervalMs);
console.log("HTTP Simulator started");
}

private async sendReadings() {
const readings = this.config.sensors.map((sensor) => ({
sensorId: sensor.id,
parameter: sensor.parameter,
value: this.generateValue(sensor),
unit: sensor.unit,
timestamp: new Date().toISOString(),
quality: "good",
}));

try {
await fetch(`${this.apiUrl}/api/iot/batch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
},
body: JSON.stringify({
readings,
plantId: this.config.plantId,
}),
});
} catch (error) {
console.error("Failed to send readings:", error);
}
}

private generateValue(sensor: SensorConfig): number {
const variance = (Math.random() - 0.5) * 2 * sensor.variance;
return parseFloat((sensor.baseValue + variance).toFixed(2));
}

stop() {
if (this.interval) clearInterval(this.interval);
}
}

3. Integration Tests

3.1 API Integration Tests

// tests/integration/iot-api.test.ts

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";

const API_URL = process.env.TEST_API_URL || "http://localhost:3000";
const API_KEY = process.env.TEST_API_KEY || "test-key";

describe("IoT API Integration", () => {
describe("POST /api/iot/ingest", () => {
it("should accept valid sensor reading", async () => {
const reading = {
sensorId: "test-sensor-001",
parameter: "pH",
value: 7.2,
unit: "pH",
quality: "good",
};

const response = await request(API_URL).post("/api/iot/ingest").set("x-api-key", API_KEY).send(reading);

expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
});

it("should reject invalid reading", async () => {
const invalidReading = {
sensorId: "test-sensor-001",
// missing parameter
value: "not-a-number",
};

const response = await request(API_URL).post("/api/iot/ingest").set("x-api-key", API_KEY).send(invalidReading);

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});

it("should reject without API key", async () => {
const reading = {
sensorId: "test-sensor-001",
parameter: "pH",
value: 7.2,
unit: "pH",
};

const response = await request(API_URL).post("/api/iot/ingest").send(reading);

expect(response.status).toBe(401);
});
});

describe("POST /api/iot/batch", () => {
it("should accept batch of readings", async () => {
const batch = {
plantId: "test-plant",
readings: [
{ sensorId: "s1", parameter: "pH", value: 7.2, unit: "pH" },
{ sensorId: "s2", parameter: "BOD", value: 25, unit: "mg/L" },
{ sensorId: "s3", parameter: "COD", value: 180, unit: "mg/L" },
],
};

const response = await request(API_URL).post("/api/iot/batch").set("x-api-key", API_KEY).send(batch);

expect(response.status).toBe(201);
expect(response.body.data.count).toBe(3);
});

it("should reject batch exceeding limit", async () => {
const largeBatch = {
plantId: "test-plant",
readings: Array(1001).fill({
sensorId: "s1",
parameter: "pH",
value: 7.2,
unit: "pH",
}),
};

const response = await request(API_URL).post("/api/iot/batch").set("x-api-key", API_KEY).send(largeBatch);

expect(response.status).toBe(400);
});
});

describe("GET /api/iot/status", () => {
it("should return health status", async () => {
const response = await request(API_URL).get("/api/iot/status").set("x-api-key", API_KEY);

expect(response.status).toBe(200);
expect(response.body.status).toBeDefined();
expect(response.body.services).toBeDefined();
});
});
});

3.2 Protocol Integration Tests

// tests/integration/mqtt.test.ts

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import mqtt from "mqtt";

describe("MQTT Integration", () => {
let client: mqtt.MqttClient;

beforeAll(async () => {
client = mqtt.connect(process.env.MQTT_BROKER_URL || "mqtt://localhost:1883");
await new Promise<void>((resolve) => client.on("connect", resolve));
});

afterAll(() => {
client.end();
});

it("should publish and receive sensor reading", async () => {
const topic = "sensors/test-plant/test-sensor/pH";
const reading = {
sensorId: "test-sensor",
parameter: "pH",
value: 7.2,
timestamp: new Date().toISOString(),
};

const received = new Promise<any>((resolve) => {
client.subscribe(topic, () => {
client.on("message", (t, msg) => {
if (t === topic) resolve(JSON.parse(msg.toString()));
});
});
});

client.publish(topic, JSON.stringify(reading));

const message = await received;
expect(message.sensorId).toBe("test-sensor");
expect(message.value).toBe(7.2);
});
});

4. Load Testing

4.1 k6 Load Test Script

// tests/load/iot-ingest.js

import http from "k6/http";
import { check, sleep } from "k6";
import { Rate, Trend } from "k6/metrics";

const errorRate = new Rate("errors");
const latency = new Trend("latency");

export const options = {
stages: [
{ duration: "1m", target: 50 }, // Ramp up
{ duration: "3m", target: 100 }, // Stay at 100 RPS
{ duration: "1m", target: 200 }, // Spike
{ duration: "2m", target: 100 }, // Back to normal
{ duration: "1m", target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ["p(95)<200"], // 95% under 200ms
errors: ["rate<0.01"], // Error rate under 1%
},
};

const API_URL = __ENV.API_URL || "http://localhost:3000";
const API_KEY = __ENV.API_KEY || "test-key";

export default function () {
const reading = {
sensorId: `sensor-${Math.floor(Math.random() * 100)}`,
parameter: ["pH", "BOD", "COD", "TSS", "TDS"][Math.floor(Math.random() * 5)],
value: Math.random() * 100,
unit: "mg/L",
timestamp: new Date().toISOString(),
quality: "good",
};

const res = http.post(`${API_URL}/api/iot/ingest`, JSON.stringify(reading), {
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
});

latency.add(res.timings.duration);

const success = check(res, {
"status is 201": (r) => r.status === 201,
"response has success": (r) => JSON.parse(r.body).success === true,
});

errorRate.add(!success);

sleep(0.1);
}

4.2 Run Load Tests

# Install k6
brew install k6 # macOS
# or
choco install k6 # Windows

# Run load test
k6 run tests/load/iot-ingest.js

# With custom parameters
k6 run -e API_URL=https://api.example.com -e API_KEY=your-key tests/load/iot-ingest.js

4.3 Batch Load Test

// tests/load/iot-batch.js

import http from "k6/http";
import { check } from "k6";

export const options = {
vus: 10,
duration: "5m",
thresholds: {
http_req_duration: ["p(95)<500"],
},
};

export default function () {
const readings = Array(100)
.fill(null)
.map((_, i) => ({
sensorId: `sensor-${i}`,
parameter: "pH",
value: 7 + Math.random(),
unit: "pH",
}));

const res = http.post(`${__ENV.API_URL}/api/iot/batch`, JSON.stringify({ readings, plantId: "load-test" }), {
headers: {
"Content-Type": "application/json",
"x-api-key": __ENV.API_KEY,
},
});

check(res, {
"batch accepted": (r) => r.status === 201,
});
}

5. End-to-End Testing

5.1 E2E Test with Playwright

// tests/e2e/iot-dashboard.spec.ts

import { test, expect } from "@playwright/test";

test.describe("IoT Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
});

test("should display live sensor data", async ({ page }) => {
// Start simulator in background (or use mock)

// Wait for sensor card to appear
const sensorCard = page.locator('[data-testid="sensor-card-pH"]');
await expect(sensorCard).toBeVisible({ timeout: 10000 });

// Check value updates
const initialValue = await sensorCard.locator(".sensor-value").textContent();
await page.waitForTimeout(2000);
const updatedValue = await sensorCard.locator(".sensor-value").textContent();

// Value should have changed (live data)
expect(updatedValue).not.toBe(initialValue);
});

test("should show alert when threshold exceeded", async ({ page }) => {
// Inject anomaly via API
await page.request.post("/api/iot/ingest", {
headers: { "x-api-key": process.env.API_KEY! },
data: {
sensorId: "test-sensor",
parameter: "BOD",
value: 150, // Above threshold
unit: "mg/L",
},
});

// Wait for alert to appear
const alert = page.locator('[data-testid="alert-banner"]');
await expect(alert).toBeVisible({ timeout: 5000 });
await expect(alert).toContainText("BOD");
});

test("should trigger ML prediction", async ({ page }) => {
// Click predict button
await page.click('[data-testid="run-prediction-btn"]');

// Wait for prediction result
const result = page.locator('[data-testid="prediction-result"]');
await expect(result).toBeVisible({ timeout: 30000 });

// Check prediction content
await expect(result).toContainText(/Class_[A-E]/);
});
});

5.2 Run E2E Tests

# Install Playwright
npm install -D @playwright/test
npx playwright install

# Run tests
npx playwright test

# Run with UI
npx playwright test --ui

# Run specific test
npx playwright test iot-dashboard.spec.ts

Quick Reference

Test Commands

# Unit tests
npm test

# Integration tests
npm run test:integration

# Load tests
npm run test:load

# E2E tests
npm run test:e2e

# All tests
npm run test:all

# Start simulator
npm run simulator

Environment Variables for Testing

# .env.test
TEST_API_URL=http://localhost:3000
TEST_API_KEY=test-api-key-12345
MQTT_BROKER_URL=mqtt://localhost:1883
INFLUXDB_URL=http://localhost:8086

Summary

This completes the IoT & Hardware Integration documentation series. The 10 documents cover:

  1. ✅ Architecture Overview
  2. ✅ Hardware Sensors
  3. ✅ Software Stack
  4. ✅ Protocol Adapters
  5. ✅ API Integration
  6. ✅ Edge Deployment
  7. ✅ Data Pipeline
  8. ✅ Neilsoft Integration
  9. ✅ Implementation Roadmap
  10. ✅ Testing & Simulation

Last Updated: December 2024