All skills
lexler avatar

/nullables

@5b607dc
by Lada Kesselerlexler/skill-factory238 stars
61

Nullables — testing technique alternative to using mocking libraries. Use when writing unit tests, when code touches external I/O or state (HTTP, databases, files, clock, random) anywhere in its dependency chain, when making a system testable, or when tests are slow or flaky.

Use this Skill: https://skilld.dev/gh/lexler/skill-factory/nullables

This session only. Nothing lands on disk.

referencesutilities.md

≈1.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Utilities: OutputListener and ConfigurableResponses

Two tiny reusable classes carry the whole approach — one for the write channel, one for the read channel. Copy them into the codebase (e.g. infrastructure/util/) when it doesn't have them; they are production code and get committed like any other. JS and Java versions are below — in another language, port them, preserving the semantics stated with each (they are well under 100 lines apiece).

Contents

  • OutputListener / OutputTracker (write channel)
  • ConfigurableResponses (read channel)

OutputListener / OutputTracker

Wrappers hold an OutputListener and emit(domainData) at the moment of each write, in code shared by real and nulled paths. Tests call trackX() to get an OutputTracker. No tracker subscribed → emit is a no-op.

JavaScript/TypeScript:

import { EventEmitter } from "node:events";

const EVENT = "output";

export class OutputListener {
  constructor() {
    this._emitter = new EventEmitter();
  }
  emit(data) {
    this._emitter.emit(EVENT, data);
  }
  trackOutput() {
    return new OutputTracker(this._emitter);
  }
}

class OutputTracker {
  constructor(emitter) {
    this._emitter = emitter;
    this._data = [];
    this._listener = (item) => this._data.push(item);
    this._emitter.on(EVENT, this._listener);
  }
  get data() {
    return this._data;
  }
  clear() {                       // return current data and reset
    const result = [...this._data];
    this._data.length = 0;
    return result;
  }
  stop() {                        // unsubscribe
    this._emitter.off(EVENT, this._listener);
  }
}

Java:

public class OutputListener<T> {
    private final List<OutputTracker<T>> listeners = new ArrayList<>();

    public void emit(T data) {
        listeners.forEach(tracker -> tracker.add(data));
    }

    public OutputTracker<T> trackOutput() {
        OutputTracker<T> tracker = new OutputTracker<>(this);
        listeners.add(tracker);
        return tracker;
    }

    void remove(OutputTracker<T> outputTracker) {
        listeners.remove(outputTracker);
    }
}

public class OutputTracker<T> {
    private final List<T> output = new ArrayList<>();
    private final OutputListener<T> outputListener;

    public OutputTracker(OutputListener<T> outputListener) {
        this.outputListener = outputListener;
    }

    void add(T data) {
        output.add(data);
    }

    public List<T> data() {
        return List.copyOf(output);
    }

    public List<T> clear() {
        List<T> data = this.data();
        output.clear();
        return data;
    }

    public void stop() {
        outputListener.remove(this);
    }
}

Usage in a wrapper:

private final OutputListener<Game.Snapshot> listener = new OutputListener<>();

public void saveGame(Game.Snapshot snapshot) {
    gameDatabaseJpa.save(GameRow.from(snapshot));
    listener.emit(snapshot);                    // domain-level, both modes
}
public OutputTracker<Game.Snapshot> trackSaves() {
    return listener.trackOutput();
}

ConfigurableResponses

Encapsulates the read-channel semantics: a single value repeats forever; a list is consumed in order and then fails fast with a named error. Used inside embedded stubs.

JavaScript/TypeScript:

export class ConfigurableResponses {
  // Array → different response each call, throws when exhausted.
  // Anything else → same response every call, never runs out.
  // 'name' appears in error messages.
  static create(responses, name) {
    return new ConfigurableResponses(responses, name);
  }

  // Convert every property of an object: { "/a": 1 } → { "/a": ConfigurableResponses }
  static mapObject(responseObject, name) {
    const entries = Object.entries(responseObject).map(([key, value]) => {
      const translatedName = name === undefined ? undefined : `${name}: ${key}`;
      return [key, ConfigurableResponses.create(value, translatedName)];
    });
    return Object.fromEntries(entries);
  }

  constructor(responses, name) {
    this._description = name === undefined ? "" : ` in ${name}`;
    this._responses = Array.isArray(responses) ? [...responses] : responses;
  }

  next() {
    const response = Array.isArray(this._responses)
      ? this._responses.shift()
      : this._responses;
    if (response === undefined) {
      throw new Error(`No more responses configured${this._description}`);
    }
    return response;
  }
}

Java idiom (iterator normalization):

private static Iterator<Object> normalize(Object responses) {
    if (responses instanceof List) {
        return ((List<Object>) responses).iterator();       // in order, then hasNext() false
    }
    return Stream.generate(() -> responses).iterator();     // repeats forever
}

private Object next(String key, Iterator<Object> responses) {
    if (!responses.hasNext()) {
        throw new NoSuchElementException("No more responses configured for: " + key);
    }
    return responses.next();
}

Usage in a stub:

class StubbedHttp {
  constructor(responses = {}) {
    this._responses = ConfigurableResponses.mapObject(responses, "nulled HTTP client");
  }
  request({ path }) {
    return new StubbedRequest(this._responses[path]?.next() ?? DEFAULT_NULLED_RESPONSE);
  }
}

Source: SKILL.md on GitHub

1 warning6d4 checks · Risk SAFE
  • Gen Agent Trust Hub6d

    The skill provides technical guidance and patterns for the 'Nullables' testing technique, which is a sociable testing strategy designed to replace mocking libraries. No security issues or malicious patterns were identified within the documentation or code examples.

  • Socket6d

    No alerts

  • Snyk6d

    Risk: LOW · No issues

  • Runlayer7mo

    12/12 files flagged

Signed by skilld at 5b607dc. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Steadyupdated 3 months ago

README badge

README badge for lexler/skill-factory/nullables