]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
test: improve test suite quality and add lifecycle helpers
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 27 Feb 2026 21:45:03 +0000 (22:45 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 27 Feb 2026 21:45:03 +0000 (22:45 +0100)
- Add TestLifecycleHelpers module with reusable setup/cleanup classes
- Add afterEach hooks to prevent test pollution
- Fix lint errors and add complete JSDoc documentation
- Update TEST_STYLE_GUIDE with acceptable eslint-disable patterns
- Rename OCPP20AuthAdapter.offline.test.ts for naming consistency

tests/TEST_STYLE_GUIDE.md
tests/charging-station/ChargingStation.test.ts
tests/charging-station/ChargingStationTestUtils.ts
tests/charging-station/Helpers.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter-Offline.test.ts [moved from tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.offline.test.ts with 100% similarity]
tests/helpers/TestLifecycleHelpers.ts [new file with mode: 0644]
tests/utils/Utils.test.ts

index a26b37578c682f66cfdb4000efa9ca4794b0cb2e..903ce704b089b6d636c30515ddee47859a2fec6d 100644 (file)
@@ -298,6 +298,37 @@ expect(result.token).toBeDefined()
 
 **Why:** Disabling linting rules hides real problems. Fix the underlying type issues instead.
 
+**Exception - Legitimate Uses of eslint-disable:**
+
+Some eslint-disable comments are acceptable when testing defensive code that validates inputs at runtime:
+
+```typescript
+// Testing that validators handle invalid types gracefully
+// This is legitimate because the function is designed to handle runtime type errors
+await it('should return false for non-string input', () => {
+  // Testing runtime type validation - intentionally passing wrong type
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
+  expect(AuthValidators.isValidIdentifierValue(123 as any)).toBe(false)
+})
+
+// Testing async function detection requires empty function expressions
+// eslint-disable-next-line @typescript-eslint/no-empty-function
+expect(isAsyncFunction(() => {})).toBe(false)
+```
+
+**Acceptable Rules to Disable (with justification):**
+
+- `@typescript-eslint/no-empty-function` - When testing function type detection
+- `@typescript-eslint/no-explicit-any` - When testing runtime type validation
+- `@typescript-eslint/unbound-method` - When testing method type detection
+- `@cspell/spellchecker` - For intentional misspellings in test data
+
+**Still NOT Acceptable:**
+
+- File-level disables (`/* eslint-disable ... */` at top of file)
+- Disabling rules to bypass type safety in test setup
+- Disabling rules because proper interfaces haven't been created
+
 ## Summary
 
 - **Name clearly**: Descriptive names for files, suites, and test cases
index 325461877b616e0136e66e6b3e95dda87da273a2..2f2a1901b6057d29143c258d9b4bf4caf59250a4 100644 (file)
@@ -9,11 +9,12 @@
  * - ChargingStation-Configuration.test.ts: boot notification, config persistence, WebSocket, error handling
  */
 import { expect } from '@std/expect'
-import { describe, it } from 'node:test'
+import { afterEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 
 import { RegistrationStatusEnumType } from '../../src/types/index.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import {
   cleanupChargingStation,
   createMockChargingStation,
@@ -25,6 +26,9 @@ import {
 
 await describe('ChargingStation Integration Tests', async () => {
   await describe('Test Utilities Verification', async () => {
+    afterEach(() => {
+      standardCleanup()
+    })
     await it('should create mock charging station with default options', () => {
       const result = createMockChargingStation()
       const station = result.station
@@ -105,6 +109,12 @@ await describe('ChargingStation Integration Tests', async () => {
   })
 
   await describe('Cross-Domain Integration', async () => {
+    afterEach(() => {
+      standardCleanup()
+      if (station != null) {
+        cleanupChargingStation(station)
+      }
+    })
     let station: ChargingStation | undefined
 
     await it('should support full lifecycle with transactions', async () => {
index c23bb1a63aa81fc668cfe164868805b96256a3b4..66c697bc49d3d507fed806ce0ef4a890551bc00b 100644 (file)
  * @see tests/charging-station/ChargingStationTestConstants.ts for test constants
  */
 
+// Re-export test lifecycle helpers
+export {
+  clearConnectorTransaction,
+  setupConnectorWithTransaction,
+  standardCleanup,
+  TestEnvironmentHelper,
+  TestStationHelper,
+  TestTimerHelper,
+} from '../helpers/TestLifecycleHelpers.js'
+export type { MockableTimerAPI, TimerHelperOptions } from '../helpers/TestLifecycleHelpers.js'
+
 // Re-export all helper functions and types
 export type {
   ChargingStationMocks,
@@ -19,6 +30,7 @@ export type {
   MockChargingStationOptions,
   MockChargingStationResult,
 } from './helpers/StationHelpers.js'
+
 export {
   cleanupChargingStation,
   createConnectorStatus,
@@ -29,6 +41,5 @@ export {
 } from './helpers/StationHelpers.js'
 
 export { MockIdTagsCache, MockSharedLRUCache } from './mocks/MockCaches.js'
-
 // Re-export all mock classes
 export { MockWebSocket, WebSocketReadyState } from './mocks/MockWebSocket.js'
index 271028f83f4623323c0ffc44ac37ef8fecf4bb8d..b26fe19086bb8d8adcffe43c8af964615dbab9e3 100644 (file)
@@ -2,10 +2,9 @@
  * @file Tests for Helpers
  * @description Unit tests for charging station helper functions and utilities
  */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
 
 import { expect } from '@std/expect'
-import { describe, it } from 'node:test'
+import { afterEach, describe, it } from 'node:test'
 
 import {
   checkChargingStationState,
@@ -35,11 +34,16 @@ import {
 } from '../../src/types/index.js'
 import { logger } from '../../src/utils/Logger.js'
 import { createChargingStation, createChargingStationTemplate } from '../ChargingStationFactory.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 
 await describe('Helpers test suite', async () => {
   const baseName = 'CS-TEST'
   const chargingStationTemplate = createChargingStationTemplate(baseName)
 
+  afterEach(() => {
+    standardCleanup()
+  })
+
   // Helper to create test reservations with configurable expiry
   const createTestReservation = (expired = false): Reservation =>
     ({
@@ -63,7 +67,7 @@ await describe('Helpers test suite', async () => {
     // For validation edge cases, we need to manually create invalid states
     // since the factory is designed to create valid configurations
     const stationNoInfo = createChargingStation({ baseName })
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
     delete (stationNoInfo as any).stationInfo
     expect(() => {
       validateStationInfo(stationNoInfo)
index 1e4a5dbe45ed78bed0faf36ae1fb36304370950d..9752ea95c35a1165621a8c76c8020b092599da7c 100644 (file)
@@ -8,7 +8,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
 import { expect } from '@std/expect'
-import { describe, it } from 'node:test'
+import { afterEach, describe, it } from 'node:test'
 
 import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
 import {
@@ -23,6 +23,7 @@ import {
   type OCPP20TransactionContext,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants, generateUUID } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
 import { createChargingStation } from '../../../ChargingStationFactory.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockOCPP20TransactionTestStation, resetLimits } from './OCPP20TestUtils.js'
@@ -33,6 +34,11 @@ await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async ()
   // Reset limits before tests
   resetLimits(mockChargingStation)
 
+  // Reset singleton state and timers after each test to ensure test isolation
+  afterEach(() => {
+    standardCleanup()
+  })
+
   // FR: E01.FR.01 - TransactionEventRequest structure validation
   await describe('buildTransactionEvent', async () => {
     await it('should build valid TransactionEvent Started with sequence number 0', () => {
index 8891b1405044a4a0a0d51cf0f4cd26da84ea51d1..605dd1c77376ff061adf54a9bfe4541aee0e3089 100644 (file)
@@ -31,6 +31,7 @@ import {
   type VariableType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
 import { createChargingStation } from '../../../ChargingStationFactory.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import {
@@ -76,6 +77,7 @@ await describe('B05/B06 - OCPP20VariableManager test suite', async () => {
 
   // Reset singleton state after each test to ensure test isolation
   afterEach(() => {
+    standardCleanup()
     OCPP20VariableManager.getInstance().resetRuntimeOverrides()
   })
 
diff --git a/tests/helpers/TestLifecycleHelpers.ts b/tests/helpers/TestLifecycleHelpers.ts
new file mode 100644 (file)
index 0000000..3919b73
--- /dev/null
@@ -0,0 +1,359 @@
+/**
+ * @file Test Lifecycle Helpers
+ * @description Reusable lifecycle helpers for test setup and cleanup to reduce boilerplate.
+ *
+ * These helpers encapsulate common beforeEach/afterEach patterns used across the test suite,
+ * ensuring consistent test isolation and preventing pollution between tests.
+ * @example
+ * ```typescript
+ * import { TestTimerHelper, withMockStation } from '../helpers/TestLifecycleHelpers.js'
+ *
+ * describe('MyTest', () => {
+ *   const timerHelper = new TestTimerHelper()
+ *
+ *   beforeEach(() => timerHelper.setup())
+ *   afterEach(() => timerHelper.cleanup())
+ *
+ *   // Or use the functional pattern
+ *   withMockStation({ connectorsCount: 2 }, (getStation) => {
+ *     it('should test something', () => {
+ *       const station = getStation()
+ *       // test with station
+ *     })
+ *   })
+ * })
+ * ```
+ */
+
+import { mock } from 'node:test'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import type {
+  MockChargingStationOptions,
+  MockChargingStationResult,
+} from '../charging-station/helpers/StationHelpers.js'
+
+import {
+  cleanupChargingStation,
+  createMockChargingStation,
+} from '../charging-station/helpers/StationHelpers.js'
+import { MockIdTagsCache, MockSharedLRUCache } from '../charging-station/mocks/MockCaches.js'
+
+/**
+ * Timer APIs that can be mocked in tests
+ */
+export type MockableTimerAPI = 'setImmediate' | 'setInterval' | 'setTimeout'
+
+/**
+ * Configuration options for TestTimerHelper
+ */
+export interface TimerHelperOptions {
+  /**
+   * Timer APIs to mock (default: all three)
+   */
+  apis?: MockableTimerAPI[]
+}
+
+/**
+ * Helper class for managing mock charging stations in tests
+ *
+ * Provides automatic cleanup of charging station resources including
+ * timers, WebSocket connections, and singleton mocks.
+ * @example
+ * ```typescript
+ * describe('MyTest', () => {
+ *   const stationHelper = new TestStationHelper({ connectorsCount: 2 })
+ *
+ *   beforeEach(() => stationHelper.setup())
+ *   afterEach(() => stationHelper.cleanup())
+ *
+ *   it('should test station', () => {
+ *     const { station, mocks } = stationHelper.get()
+ *     // test with station
+ *   })
+ * })
+ * ```
+ */
+export class TestStationHelper {
+  private readonly options: MockChargingStationOptions
+  private result: MockChargingStationResult | null = null
+
+  constructor (options: MockChargingStationOptions = {}) {
+    this.options = options
+  }
+
+  /**
+   * Clean up the charging station (call in afterEach)
+   */
+  cleanup (): void {
+    if (this.result != null) {
+      cleanupChargingStation(this.result.station)
+      this.result = null
+    }
+    // Also reset singleton mocks
+    MockSharedLRUCache.resetInstance()
+    MockIdTagsCache.resetInstance()
+  }
+
+  /**
+   * Get the current mock result (throws if not setup)
+   * @returns The mock charging station result
+   */
+  get (): MockChargingStationResult {
+    if (this.result == null) {
+      throw new Error('TestStationHelper.setup() must be called before get()')
+    }
+    return this.result
+  }
+
+  /**
+   * Get just the station (convenience method)
+   * @returns The charging station instance
+   */
+  getStation (): ChargingStation {
+    return this.get().station
+  }
+
+  /**
+   * Check if station is currently setup
+   * @returns True if station is setup
+   */
+  isSetup (): boolean {
+    return this.result != null
+  }
+
+  /**
+   * Create the mock charging station (call in beforeEach)
+   * @returns The mock charging station result
+   */
+  setup (): MockChargingStationResult {
+    this.result = createMockChargingStation(this.options)
+    return this.result
+  }
+}
+
+/**
+ * Helper class for managing mock timers in tests
+ *
+ * Encapsulates the common pattern of enabling and resetting mock timers,
+ * ensuring consistent cleanup and preventing timer leaks between tests.
+ * @example
+ * ```typescript
+ * describe('MyTest', () => {
+ *   const timerHelper = new TestTimerHelper()
+ *
+ *   beforeEach(() => timerHelper.setup())
+ *   afterEach(() => timerHelper.cleanup())
+ *
+ *   it('should handle timer-based logic', () => {
+ *     // Timer-dependent code works with mock timers
+ *     mock.timers.tick(1000)
+ *   })
+ * })
+ * ```
+ */
+export class TestTimerHelper {
+  private readonly apis: MockableTimerAPI[]
+  private isSetup = false
+
+  constructor (options: TimerHelperOptions = {}) {
+    this.apis = options.apis ?? ['setInterval', 'setTimeout', 'setImmediate']
+  }
+
+  /**
+   * Reset mock timers (call in afterEach)
+   */
+  cleanup (): void {
+    if (this.isSetup) {
+      mock.timers.reset()
+      this.isSetup = false
+    }
+  }
+
+  /**
+   * Enable mock timers (call in beforeEach)
+   */
+  setup (): void {
+    if (!this.isSetup) {
+      mock.timers.enable({ apis: this.apis })
+      this.isSetup = true
+    }
+  }
+
+  /**
+   * Advance mock timers by specified milliseconds
+   * @param ms - Milliseconds to advance
+   */
+  tick (ms: number): void {
+    mock.timers.tick(ms)
+  }
+}
+
+/**
+ * Combined helper for tests that need both timers and station
+ * @example
+ * ```typescript
+ * describe('MyTest', () => {
+ *   const helper = new TestEnvironmentHelper({ connectorsCount: 2 })
+ *
+ *   beforeEach(() => helper.setup())
+ *   afterEach(() => helper.cleanup())
+ *
+ *   it('should test with timers and station', () => {
+ *     const station = helper.getStation()
+ *     helper.tick(1000) // Advance time
+ *   })
+ * })
+ * ```
+ */
+export class TestEnvironmentHelper {
+  private readonly stationHelper: TestStationHelper
+  private readonly timerHelper: TestTimerHelper
+
+  constructor (
+    stationOptions: MockChargingStationOptions = {},
+    timerOptions: TimerHelperOptions = {}
+  ) {
+    this.timerHelper = new TestTimerHelper(timerOptions)
+    this.stationHelper = new TestStationHelper(stationOptions)
+  }
+
+  /**
+   * Cleanup both timers and station
+   */
+  cleanup (): void {
+    this.stationHelper.cleanup()
+    this.timerHelper.cleanup()
+    mock.restoreAll()
+  }
+
+  /**
+   * Get the mock station result
+   * @returns The mock charging station result
+   */
+  get (): MockChargingStationResult {
+    return this.stationHelper.get()
+  }
+
+  /**
+   * Get just the station
+   * @returns The charging station instance
+   */
+  getStation (): ChargingStation {
+    return this.stationHelper.getStation()
+  }
+
+  /**
+   * Setup both timers and station
+   * @returns The mock charging station result
+   */
+  setup (): MockChargingStationResult {
+    this.timerHelper.setup()
+    return this.stationHelper.setup()
+  }
+
+  /**
+   * Advance mock timers
+   * @param ms - Milliseconds to advance
+   */
+  tick (ms: number): void {
+    this.timerHelper.tick(ms)
+  }
+}
+
+/**
+ * Clear transaction state from a connector
+ * @param station - ChargingStation instance
+ * @param connectorId - Connector to clear
+ */
+export function clearConnectorTransaction (station: ChargingStation, connectorId: number): void {
+  const connector = station.getConnectorStatus(connectorId)
+  if (connector == null) {
+    return
+  }
+
+  connector.transactionStarted = false
+  connector.transactionId = undefined
+  connector.transactionIdTag = undefined
+  connector.transactionEnergyActiveImportRegisterValue = 0
+  connector.transactionRemoteStarted = false
+  connector.transactionStart = undefined
+  connector.idTagAuthorized = false
+  connector.idTagLocalAuthorized = false
+
+  // Clear any transaction interval
+  if (connector.transactionSetInterval != null) {
+    clearInterval(connector.transactionSetInterval)
+    connector.transactionSetInterval = undefined
+  }
+}
+
+/**
+ * Setup a connector with an active transaction
+ *
+ * Reduces boilerplate when tests need a connector in transaction state.
+ * @param station - ChargingStation instance
+ * @param connectorId - Connector to setup
+ * @param options - Transaction options
+ * @param options.transactionId - Transaction ID to set
+ * @param options.idTag - ID tag for the transaction (default: TAG-{transactionId})
+ * @param options.energyImport - Energy import value in Wh (default: 0)
+ * @param options.remoteStarted - Whether transaction was remote started (default: false)
+ * @example
+ * ```typescript
+ * setupConnectorWithTransaction(station, 1, {
+ *   transactionId: 100,
+ *   idTag: 'TEST-TAG-001',
+ *   energyImport: 1000
+ * })
+ * ```
+ */
+export function setupConnectorWithTransaction (
+  station: ChargingStation,
+  connectorId: number,
+  options: {
+    energyImport?: number
+    idTag?: string
+    remoteStarted?: boolean
+    transactionId: number
+  }
+): void {
+  const connector = station.getConnectorStatus(connectorId)
+  if (connector == null) {
+    throw new Error(`Connector ${String(connectorId)} not found`)
+  }
+
+  connector.transactionStarted = true
+  connector.transactionId = options.transactionId
+  connector.transactionIdTag = options.idTag ?? `TAG-${String(options.transactionId)}`
+  connector.transactionEnergyActiveImportRegisterValue = options.energyImport ?? 0
+  connector.transactionRemoteStarted = options.remoteStarted ?? false
+  connector.transactionStart = new Date()
+  connector.idTagAuthorized = true
+}
+
+/**
+ * Standard afterEach cleanup function
+ *
+ * Use this when you need a simple cleanup without the helper classes.
+ * Restores all mocks and resets timers.
+ * @example
+ * ```typescript
+ * afterEach(() => {
+ *   standardCleanup()
+ *   if (station != null) {
+ *     cleanupChargingStation(station)
+ *   }
+ * })
+ * ```
+ */
+export function standardCleanup (): void {
+  mock.restoreAll()
+  try {
+    mock.timers.reset()
+  } catch {
+    // Timers may not have been enabled, ignore
+  }
+  MockSharedLRUCache.resetInstance()
+  MockIdTagsCache.resetInstance()
+}
index a980380d663a0d59c27f7b015d4d283c8891c2d7..8b3db1a48f92106a79a995ae5742366de2595add 100644 (file)
@@ -7,7 +7,7 @@ import { hoursToMilliseconds, hoursToSeconds } from 'date-fns'
 import { CircularBuffer } from 'mnemonist'
 import { randomInt } from 'node:crypto'
 import { version } from 'node:process'
-import { describe, it } from 'node:test'
+import { afterEach, describe, it } from 'node:test'
 import { satisfies } from 'semver'
 
 import type { TimestampedData } from '../../src/types/index.js'
@@ -43,8 +43,12 @@ import {
   validateIdentifierString,
   validateUUID,
 } from '../../src/utils/Utils.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 
 await describe('Utils test suite', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
   await it('should verify generateUUID()/validateUUID()', () => {
     const uuid = generateUUID()
     expect(uuid).toBeDefined()