@reldens/skills Testing Guide
How to run and write tests for the @reldens/skills package: test runner, helpers, mocks, fixtures, testing rules for event-driven code, proven patterns and debugging.
Overview
The package uses the Node.js built-in test runner (node:test and node:assert). The runner at tests/run-tests.js:
- Discovers every file that starts with test- and ends with .js inside tests/unit/ (including sub-folders).
- Runs the files concurrently with the spec reporter.
- Supports --filter= to run only the files whose path contains the given text.
- Sets a failing exit code when a test fails or no test file matches.
Running Tests
# run all tests
npm test
# watch mode, re-runs on file changes
npm run test:watch
# run only the files whose path contains the filter
npm test -- --filter=attack
npm test -- --filter=levels-set
node tests/run-tests.js --filter=skill
# coverage report
npm run test:coverage
Test Structure
- tests/run-tests.js - test runner.
- tests/utils/test-helpers.js - TestHelpers.
- tests/fixtures/
- mocks/ - MockOwner, MockTarget, MockClient.
- skills/base-skills.js - skill data fixtures.
- levels/base-levels.js - level fixtures.
- tests/unit/
- test-skill.js, test-level.js, test-levels-set.js, test-class-path.js, test-server.js
- types/ - attack, effect, physical attack, physical effect, physical skill runner and physical properties validator tests.
- server/test-sender.js, client/test-receiver.js
Test files mirror the source files: lib/skill.js is tested in tests/unit/test-skill.js, lib/types/attack.js in tests/unit/types/test-attack.js, lib/server/sender.js in tests/unit/server/test-sender.js.
Writing Tests
Basic test structure
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const Skill = require('../../lib/skill');
const { TestHelpers } = require('../utils/test-helpers');
const { MockOwner } = require('../fixtures/mocks/mock-owner');
const { MockTarget } = require('../fixtures/mocks/mock-target');
describe('Skill', () => {
let mockOwner;
let mockTarget;
beforeEach(() => {
TestHelpers.clearEventListeners();
mockOwner = new MockOwner();
mockTarget = new MockTarget();
});
afterEach(() => {
TestHelpers.clearEventListeners();
});
describe('Constructor', () => {
it('should initialize with basic properties', () => {
let skill = new Skill({key: 'test', owner: mockOwner});
assert.strictEqual(skill.key, 'test');
assert.strictEqual(skill.isReady, true);
});
});
});
Test helpers
const { TestHelpers } = require('../utils/test-helpers');
let owner = TestHelpers.createMockOwner('owner-1'); // plain object with id, eventsPrefix, getPosition() and stats
let target = TestHelpers.createMockTarget('target-1');
let client = TestHelpers.createMockClient(); // send(), broadcast(), sentMessages, broadcastMessages
let modifier = TestHelpers.createMockModifier('hp', 1, 10, 'stats/hp'); // apply() / revert() stubs
let uniqueKey = TestHelpers.generateUniqueId('listener'); // unique remove keys
TestHelpers.clearEventListeners(); // removes all the EventsManager listeners
await TestHelpers.waitForCondition(() => skill.canActivate, 1500); // polls until true or timeout
await TestHelpers.sleep(100);
Fixtures
const { BaseSkillsFixtures } = require('../fixtures/skills/base-skills');
const { BaseLevelsFixtures } = require('../fixtures/levels/base-levels');
// skill data: basicSkill, attackSkill, effectSkill, physicalAttackSkill, rangedSkill, buffSkill
let skill = new Attack({...BaseSkillsFixtures.attackSkill, owner: mockOwner});
let level = BaseLevelsFixtures.createBasicLevel(1, 0);
let levelWithModifiers = BaseLevelsFixtures.createLevelWithModifiers(5, 500);
let levels = BaseLevelsFixtures.createLevelSet(); // levels 1, 2, 3 and 5
Testing async methods
it('should execute the skill successfully', async () => {
let skill = new Skill({key: 'test', owner: mockOwner});
let result = await skill.execute(mockTarget);
assert.strictEqual(result, true);
});
Testing events
it('should fire LEVEL_UP with the levels set', async () => {
let receivedLevelsSet = null;
levelsSet.listenEvent(
SkillsEvents.LEVEL_UP,
(data) => {
receivedLevelsSet = data;
},
TestHelpers.generateUniqueId('level-up-test')
);
await levelsSet.levelUp();
assert.strictEqual(receivedLevelsSet, levelsSet);
});
Testing error conditions
it('should not validate when the skill is not ready', () => {
let skill = new Skill({owner: mockOwner}); // missing key
assert.strictEqual(skill.isReady, false);
assert.strictEqual(skill.validate(), false);
});
Testing with modifiers and conditions
const { Modifier, Condition, ModifierConst } = require('@reldens/modifiers');
let modifier = new Modifier({
key: 'hp-boost',
propertyKey: 'stats/hp',
operation: ModifierConst.OPS.INC,
value: 10
});
modifier.apply(mockOwner);
assert.strictEqual(mockOwner.stats.hp, 110);
modifier.revert(mockOwner);
assert.strictEqual(mockOwner.stats.hp, 100);
let condition = new Condition({
key: 'hp-check',
propertyKey: 'stats/hp',
conditional: ModifierConst.COMPARE.GT,
value: 50
});
assert.strictEqual(condition.isValidOn(mockOwner), true);
Testing range validation
it('should validate the range', () => {
let skill = new Skill({key: 'test', owner: mockOwner, range: 50});
assert.strictEqual(skill.isInRange({x: 100, y: 100}, {x: 110, y: 110}), true);
assert.strictEqual(skill.isInRange({x: 0, y: 0}, {x: 1000, y: 1000}), false);
});
Mock Objects
MockOwner
Simulates a skill owner (player, NPC). new MockOwner(id, position), defaults 'mock-owner-1' and {x: 100, y: 100}.
- id, position, events (EventsManagerSingleton), eventsPrefix (skills.ownerId.{id}).
- stats - atk 10, def 5, hp 100, maxHp 100, mp 50, maxMp 50, aim 15, dodge 8, stamina 100.
- isCasting, castingTimer, currentSkills.
- Methods: getPosition(), setPosition(x, y), updateStat(key, value), eventUniqueKey().
MockTarget
Simulates a skill target. new MockTarget(id, position), defaults 'mock-target-1' and {x: 110, y: 110}.
- id, position.
- stats - atk 8, def 6, hp 80, maxHp 80, mp 40, maxMp 40, aim 12, dodge 10, stamina 80, speed 0.
- Methods: getPosition(), setPosition(x, y), updateStat(key, value).
MockClient
Simulates the client connection used by the Sender.
const { MockClient } = require('../fixtures/mocks/mock-client');
const Sender = require('../../lib/server/sender');
let client = new MockClient();
let sender = new Sender(classPath, client); // classPath already initialized
await sender.sendLevelUpData(classPath);
assert.strictEqual(client.sentMessages.length, 1);
assert.strictEqual(client.getLastSentMessage().act, SkillConst.ACTION_LEVEL_UP);
assert.strictEqual(client.broadcastMessages.length, 0);
client.clearMessages();
Critical Testing Rules
Rule 1: do not test through events unless the event system is under test
Event chains add hidden dependencies: some events are fired without awaiting them (inside validate(), isInRange() and the SkillsServer initialization), a listener silently fails to register when its remove key was already used, and the shared EventsManager connects every test. Calling the method under test directly is deterministic.
// AVOID - depends on the listeners wiring
it('should send the level up message', async () => {
let sender = new Sender(classPath, mockClient);
sender.registerListeners();
await classPath.levelUp();
assert.strictEqual(mockClient.sentMessages.length, 1);
});
// PREFER - calls the method under test
it('should send the level up message', async () => {
let sender = new Sender(classPath, mockClient);
await sender.sendLevelUpData(classPath);
assert.strictEqual(mockClient.sentMessages.length, 1);
});
Test through events when the test is about the event itself: that it fires, its parameters or the order of a sequence.
Rule 2: never use sleep() to fix race conditions
A sleep() hides the problem and makes the test time-dependent. Use it only to test real timers such as skillDelay and castTime:
it('should restore canActivate after the skill delay', async () => {
let skill = new Skill({key: 'cooldown', owner: mockOwner, skillDelay: 100});
skill.validate();
assert.strictEqual(skill.canActivate, false);
await TestHelpers.sleep(150);
assert.strictEqual(skill.canActivate, true);
});
Rule 3: always use unique remove keys
The EventsManager keeps a registry of remove keys that removeAllListeners() does not clear. Reusing a key in another test makes listenEvent() return false and the listener is never added.
// WRONG - the second test reuses 'before-listener' and its listener is ignored
classPath.listenEvent(SkillsEvents.ADD_SKILLS_BEFORE, callback, 'before-listener');
classPath.listenEvent(SkillsEvents.REMOVE_SKILLS_BEFORE, callback, 'before-listener');
// RIGHT - descriptive, namespaced keys: {component}.{suite}.{test}.{purpose}
classPath.listenEvent(SkillsEvents.ADD_SKILLS_BEFORE, callback, 'class-path.events.add-skills.before');
classPath.listenEvent(SkillsEvents.REMOVE_SKILLS_BEFORE, callback, 'class-path.events.remove-skills.before');
TestHelpers.generateUniqueId(prefix) is another option for unique keys.
Rule 4: always clear the event listeners
Listeners persist between tests. Call TestHelpers.clearEventListeners() (which runs EventsManagerSingleton.removeAllListeners()) in beforeEach and afterEach. It removes the listeners but not the remove keys registry, which is why Rule 3 matters.
Rule 5: test observable state, not the implementation
// AVOID - checks that an internal method was called
skill.applyModifiers = function(...args){ called = true; };
// PREFER - checks the outcome
it('should apply the target effects to the target', async () => {
let initialHp = mockTarget.stats.hp;
let skill = new Effect({
key: 'heal',
owner: mockOwner,
targetEffects: [new Modifier({
key: 'hp-buff',
propertyKey: 'stats/hp',
operation: ModifierConst.OPS.INC,
value: 20
})]
});
await skill.execute(mockTarget);
assert.strictEqual(mockTarget.stats.hp, initialHp + 20);
});
Rule 6: use fixtures for consistent data
Build the test data from the fixtures instead of repeating slightly different literals in every test:
let skill = new Attack({...BaseSkillsFixtures.attackSkill, owner: mockOwner});
Rule 7: mock external dependencies
Unit tests must not depend on real physics engines, networks or databases. Mock the owner physics method and trigger the hit yourself:
it('should apply damage when the projectile hits', async () => {
let mockOwner = new MockOwner();
mockOwner.executePhysicalSkill = async (target, skill) => {
await skill.executeOnHit(target);
};
let skill = new PhysicalAttack({
key: 'arrow',
owner: mockOwner,
affectedProperty: 'stats/hp',
hitDamage: 10,
magnitude: 100,
objectWidth: 10,
objectHeight: 10,
rangePropertyX: 'position/x',
rangePropertyY: 'position/y'
});
let initialHp = mockTarget.stats.hp;
await skill.execute(mockTarget);
assert.ok(mockTarget.stats.hp < initialHp);
});
Rule 8: test edge cases and boundaries
it('should treat range 0 as infinite', () => {
let skill = new Skill({key: 'global', owner: mockOwner, range: 0});
assert.strictEqual(skill.isInRange({x: 0, y: 0}, {x: 5000, y: 5000}), true);
});
it('should return false without a target', async () => {
let skill = new Effect({key: 'buff', owner: mockOwner, targetEffects: []});
assert.strictEqual(await skill.execute(undefined), false);
});
it('should not go below 0 when allowEffectBelowZero is false', async () => {
mockTarget.stats.hp = 50;
let skill = new Attack({key: 'smash', owner: mockOwner, affectedProperty: 'stats/hp', hitDamage: 100});
await skill.execute(mockTarget);
assert.strictEqual(mockTarget.stats.hp, 0);
});
Also cover 0 damage, empty ownerEffects, missing required props and maximum level boundaries.
Rule 9: use descriptive test names
Follow the pattern "should [expected behavior] when [condition]":
- should apply damage to target hp when the attack succeeds
- should set the DODGED state when dodge is greater than aim * dodgeOverAimSuccess
- should add the critical bonus when the critical roll succeeds
Avoid names like "test 1", "attack works" or "check dodge".
Rule 10: one assertion per logical concept
describe('Skill execution', () => {
it('should increment uses after the execution', async () => {
await skill.execute(mockTarget);
assert.strictEqual(skill.uses, 1);
});
it('should start the cooldown on validation', () => {
skill.validate();
assert.strictEqual(skill.canActivate, false);
});
it('should apply the damage to the target hp', async () => {
let initialHp = mockTarget.stats.hp;
await skill.execute(mockTarget);
assert.strictEqual(mockTarget.stats.hp, initialHp - skill.hitDamage);
});
});
Several assertions are fine when they verify the same concept (for example, the value and the type of a calculated damage).
Common Anti-patterns
- Testing internal helpers instead of behavior - assert the final state (target hp, current level, sent messages), not the intermediate calculations.
- Over-mocking - replacing validate(), runSkillLogic() and fireEvent() leaves nothing real under test; only mock external dependencies such as the owner physics or the client.
- Testing framework code - do not test that the EventsManager emits; test that your class fires its events.
- Assertion-less tests - a test without assertions passes even when the code is broken; at least assert the result.
- Dependent tests - each test must create its own instances; never rely on a previous test having run.
Proven Patterns
Arrange, act, assert
it('should apply the damage to the target hp', async () => {
// arrange
let initialHp = mockTarget.stats.hp;
let skill = new Attack({key: 'slash', owner: mockOwner, affectedProperty: 'stats/hp', hitDamage: 50});
// act
await skill.execute(mockTarget);
// assert
assert.strictEqual(mockTarget.stats.hp, initialHp - 50);
});
Parameterized tests
describe('Damage with attack and defense', () => {
let testCases = [
{atk: 60, def: 50, expectedDamage: 60},
{atk: 50, def: 50, expectedDamage: 50},
{atk: 40, def: 50, expectedDamage: 38}
];
for(let testCase of testCases){
it('should deal '+testCase.expectedDamage+' damage with atk '+testCase.atk+' and def '+testCase.def, async () => {
mockOwner.stats.atk = testCase.atk;
mockTarget.stats.def = testCase.def;
let skill = new Attack({
key: 'slash',
owner: mockOwner,
affectedProperty: 'stats/hp',
hitDamage: 50,
attackProperties: ['stats/atk'],
defenseProperties: ['stats/def']
});
let initialHp = mockTarget.stats.hp;
await skill.execute(mockTarget);
assert.strictEqual(initialHp - mockTarget.stats.hp, testCase.expectedDamage);
});
}
});
Test doubles
// stub: force a controlled response
it('should fail with OUT_OF_RANGE when the target moved away', async () => {
let skill = new Effect({key: 'buff', owner: mockOwner, targetEffects: []});
skill.isInRange = () => false;
assert.strictEqual(await skill.execute(mockTarget), false);
assert.strictEqual(skill.lastState, SkillConst.SKILL_STATES.OUT_OF_RANGE);
});
// spy: count the calls
it('should fire SKILL_AFTER_EXECUTE on every execution', async () => {
let callCount = 0;
skill.listenEvent(
SkillsEvents.SKILL_AFTER_EXECUTE,
() => callCount++,
TestHelpers.generateUniqueId('spy-after-execute')
);
await skill.execute(mockTarget);
await skill.execute(mockTarget);
assert.strictEqual(callCount, 2);
});
Error states
it('should not validate while the owner is casting', () => {
let skill = new Skill({key: 'test', owner: mockOwner});
mockOwner.isCasting = true; // set after creating the skill, the constructor resets it
assert.strictEqual(skill.validate(), false);
assert.strictEqual(skill.lastState, SkillConst.SKILL_STATES.CAN_NOT_ACTIVATE);
});
Assertions
assert.strictEqual(skill.key, 'test-skill');
assert.deepStrictEqual(Object.keys(classPath.currentSkills), ['sword']);
assert.ok(skill.isReady);
assert.ok(!skill.canActivate);
assert.strictEqual(typeof skill.key, 'string');
assert.ok(Array.isArray(skill.ownerConditions));
assert.ok(mockTarget.stats.hp < initialHp);
Test File Template
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const Skill = require('../../lib/skill');
const { MockOwner } = require('../fixtures/mocks/mock-owner');
const { MockTarget } = require('../fixtures/mocks/mock-target');
const { TestHelpers } = require('../utils/test-helpers');
describe('Skill - Feature description', () => {
let mockOwner;
let mockTarget;
beforeEach(() => {
TestHelpers.clearEventListeners();
mockOwner = new MockOwner();
mockTarget = new MockTarget();
});
afterEach(() => {
TestHelpers.clearEventListeners();
});
describe('Sub-feature', () => {
it('should do X when Y', async () => {
// arrange
// act
// assert
});
});
});
File naming: start with test-, end with .js, live in tests/unit/ or a sub-folder, and match the source file they test.
Debugging Tests
# run a single area
npm test -- --filter=skill
# attach a debugger (add debugger statements in the test)
node --inspect tests/run-tests.js --filter=skill
When a test fails, investigate before changing anything:
- Read the complete test and the production code it exercises.
- Understand what the test expects and what the code does.
- Add logging to trace the execution and find the exact line that produces the wrong result.
- Understand why it is wrong and prove it (compare with similar code or the specification).
- Apply a targeted fix and verify it, then look for the same issue in similar code.
Do not guess, do not try random fixes, do not use sleep() to hide race conditions, and never delete a test or change it to match broken code.
Lessons Learned
- The remove keys registry persists - removeAllListeners() does not clear it, so tests that reuse remove keys fail silently. Use unique keys.
- The critical bonus applies to the value, not the sum - effects add getCriticalDiff(modifier.value) to the modified value; applying the critical to the whole result doubles the current value. With current 80, modifier +10 and multiplier 2, the right result is 100, not 180.
- Call methods directly - testing through events couples the test to the wiring; call the method under test unless the event is what you are testing.
- sleep() is for timers - use it to test skillDelay and castTime, never to wait for code that can be awaited.
Best Practices
- Clear the event listeners in beforeEach and afterEach.
- Use fixtures for complex test data.
- Test edge cases: missing values, boundaries and error states.
- Always await async methods.
- Keep one logical concept per test with a descriptive name.
- Mock only external dependencies.
- Verify the events your code fires and the state before and after each operation.
Coverage
npm run test:coverage
The report shows line, branch and function coverage. Recommended targets: at least 80% overall, full coverage for the critical paths (damage calculation, level progression) and every error path tested.
Related Documentation
- @reldens/skills Architecture - Package overview.
- Skills Event System - Remove keys and event rules.
- Skills Execution Flow - What each test exercises.
- Skills Level Progression - Levels and class paths behavior.
- Skills Class Hierarchy - Classes reference.
reldens