10 - Testing & Simulation
Sensor simulation, testing strategies, and quality assurance
Table of Contents
1. Testing Strategy
Test Pyramid
┌─────────────┐
│ E2E │ ← Few, slow, expensive
│ Tests │
├─────────────┤
│ Integration │ ← Medium amount
│ Tests │
├─────────────┤
│ Unit │ ← Many, fast, cheap
│ Tests │
└─────────────┘
Test Coverage Goals
| Layer | Coverage | Tools |
|---|---|---|
| Unit Tests | 80% | Jest, Vitest |
| Integration Tests | 60% | Supertest, Testcontainers |
| E2E Tests | Critical paths | Playwright, Cypress |
| Load Tests | Key endpoints | k6, 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:
- ✅ Architecture Overview
- ✅ Hardware Sensors
- ✅ Software Stack
- ✅ Protocol Adapters
- ✅ API Integration
- ✅ Edge Deployment
- ✅ Data Pipeline
- ✅ Neilsoft Integration
- ✅ Implementation Roadmap
- ✅ Testing & Simulation
Last Updated: December 2024