Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@iklab/testkit",
"version": "1.0.0",

"version": "1.1.0",
"description": "Test quality toolkit — boundary values, flakiness prediction, duplicate detection, static test scanner.",
"author": "Ihor Kosheliev",
"license": "MIT",
Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,39 @@ describe("boundaries — structure", () => {
}
});
});

describe("boundaries.custom", () => {
it("returns provided values", () => {
const result = boundaries.custom({
valid: [18, 25, 65],
invalid: [-1, 0, 17, 151],
boundary: [18, 150],
});
expect(result.valid).toEqual([18, 25, 65]);
expect(result.invalid).toEqual([-1, 0, 17, 151]);
expect(result.boundary).toEqual([18, 150]);
});

it("defaults boundary to empty array", () => {
const result = boundaries.custom({
valid: ["a"],
invalid: [""],
});
expect(result.boundary).toEqual([]);
});

it("throws on missing valid array", () => {
expect(() => boundaries.custom({ valid: null as any, invalid: [] })).toThrow(TypeError);
});

it("throws on missing invalid array", () => {
expect(() => boundaries.custom({ valid: [], invalid: null as any })).toThrow(TypeError);
});

it("works with testEach", () => {
const { testEach } = require("../test-each");
const result = boundaries.custom({ valid: [1], invalid: [0], boundary: [1] });
const rows = testEach(result);
expect(rows.length).toBeGreaterThan(0);
});
});
64 changes: 64 additions & 0 deletions src/__tests__/coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { coverage } from "../coverage";

describe("coverage", () => {
it("matches tests to requirements", () => {
const result = coverage(
["should login with valid credentials", "should show error for wrong password"],
["User can login", "User sees error on invalid password", "User can reset password"],
{ threshold: 0.2 }
);
expect(result.coveragePercent).toBe(67);
expect(result.covered).toHaveLength(2);
expect(result.uncovered).toEqual(["User can reset password"]);
});

it("returns 100% when all requirements covered", () => {
const result = coverage(
["should create user", "should delete user"],
["Create user", "Delete user"]
);
expect(result.coveragePercent).toBe(100);
expect(result.uncovered).toHaveLength(0);
});

it("returns 0% when nothing matches", () => {
const result = coverage(
["should render homepage"],
["User can upload avatar"]
);
expect(result.coveragePercent).toBe(0);
expect(result.uncovered).toHaveLength(1);
});

it("handles empty arrays", () => {
expect(coverage([], []).coveragePercent).toBe(100);
expect(coverage(["test"], []).coveragePercent).toBe(100);
expect(coverage([], ["req"]).coveragePercent).toBe(0);
});

it("respects custom threshold", () => {
const result = coverage(
["should show products"],
["Display product catalog"],
{ threshold: 0.5 }
);
// Higher threshold = fewer matches
expect(result.coveragePercent).toBeLessThanOrEqual(100);
});

it("provides mapping with matched tests", () => {
const result = coverage(
["should create user account", "should validate email"],
["User registration"],
{ threshold: 0.2 }
);
expect(result.mapping).toHaveLength(1);
expect(result.mapping[0].requirement).toBe("User registration");
expect(result.mapping[0].covered).toBe(true);
});

it("throws on invalid input", () => {
expect(() => coverage(null as any, [])).toThrow(TypeError);
expect(() => coverage([], null as any)).toThrow(TypeError);
});
});
43 changes: 43 additions & 0 deletions src/__tests__/suggest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { suggest } from "../suggest";

describe("suggest", () => {
it("suggests negative case for create operations", () => {
const result = suggest("should create a new user");
expect(result.suggestions.some(s => s.includes("negative"))).toBe(true);
});

it("suggests auth for login-related tests", () => {
const result = suggest("should login with valid credentials");
expect(result.suggestions.some(s => s.includes("auth"))).toBe(true);
});

it("suggests boundary for search operations", () => {
const result = suggest("should search products by name");
expect(result.suggestions.some(s => s.includes("boundary") || s.includes("special characters"))).toBe(true);
});

it("suggests file edge cases for upload operations", () => {
const result = suggest("should upload user avatar");
expect(result.suggestions.some(s => s.includes("file") || s.includes("oversized"))).toBe(true);
});

it("suggests error handling for payment operations", () => {
const result = suggest("should process payment checkout");
expect(result.suggestions.some(s => s.includes("error") || s.includes("fail"))).toBe(true);
});

it("returns generic suggestions for vague descriptions", () => {
const result = suggest("should work");
expect(result.suggestions.length).toBeGreaterThan(0);
});

it("returns generic suggestions for empty input", () => {
const result = suggest("");
expect(result.suggestions).toHaveLength(4);
});

it("score reflects number of suggestions", () => {
const result = suggest("should create user");
expect(result.score).toBe(result.suggestions.length);
});
});
10 changes: 10 additions & 0 deletions src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,14 @@ export const boundaries = {
password: passwordBoundaries,
phone: phoneBoundaries,
uuid: uuidBoundaries,
custom(rules: { valid: unknown[]; invalid: unknown[]; boundary?: unknown[] }): BoundaryResult {
if (!rules || !Array.isArray(rules.valid) || !Array.isArray(rules.invalid)) {
throw new TypeError("custom() requires { valid: unknown[], invalid: unknown[] }");
}
return {
valid: [...rules.valid],
invalid: [...rules.invalid],
boundary: rules.boundary ? [...rules.boundary] : [],
};
},
};
48 changes: 48 additions & 0 deletions src/coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { CoverageResult, CoverageMapping } from "./types";

export function coverage(
tests: string[],
requirements: string[],
options?: { threshold?: number }
): CoverageResult {
const threshold = Math.max(0, Math.min(1, options?.threshold ?? 0.3));

if (!Array.isArray(tests) || !Array.isArray(requirements)) {
throw new TypeError("coverage() requires arrays of strings");
}

const mapping: CoverageMapping[] = requirements.map((req) => {
const matchedTests = tests
.map((test, idx) => ({ test, idx, sim: similarity(req, test) }))
.filter((m) => m.sim >= threshold)
.sort((a, b) => b.sim - a.sim)
.map((m) => m.test);
return { requirement: req, matchedTests, covered: matchedTests.length > 0 };
});

const covered = mapping.filter((m) => m.covered).map((m) => m.requirement);
const uncovered = mapping.filter((m) => !m.covered).map((m) => m.requirement);
const coveragePercent = requirements.length > 0
? Math.round((covered.length / requirements.length) * 100)
: 100;

return { covered, uncovered, coveragePercent, mapping };
}

// Local Jaccard similarity (same algorithm as duplicates.ts but decoupled)
function tokenize(text: string): Set<string> {
const STOP_WORDS = new Set(["a","an","the","is","are","was","were","be","been","being","have","has","had","do","does","did","will","would","could","should","may","might","shall","can","need","must","to","of","in","for","on","with","at","by","from","as","into","about","between","through","after","before","during","and","but","or","not","no","it","its","this","that","these","those","i","we","you","he","she","they","my","our","your"]);
return new Set(
text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 1 && !STOP_WORDS.has(w))
);
}

function similarity(a: string, b: string): number {
const setA = tokenize(a);
const setB = tokenize(b);
if (setA.size === 0 || setB.size === 0) return 0;
let intersection = 0;
for (const token of setA) if (setB.has(token)) intersection++;
const union = new Set([...setA, ...setB]).size;
return union > 0 ? intersection / union : 0;
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export { boundaries } from "./boundaries";
export { flaky } from "./flaky";
export { testEach } from "./test-each";
export { detectDuplicates } from "./duplicates";
export { coverage } from "./coverage";
export { suggest } from "./suggest";

export type {
BoundaryResult,
Expand All @@ -18,4 +20,7 @@ export type {
DuplicateOptions,
TestEachOptions,
TestEachRow,
CoverageResult,
CoverageMapping,
SuggestionResult,
} from "./types";
52 changes: 52 additions & 0 deletions src/suggest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { SuggestionResult } from "./types";

const PATTERNS: { pattern: RegExp; category: string; suggestion: string }[] = [
{ pattern: /^should\s+(create|add|save|insert|post|submit|register|sign.?up)/i, category: "negative", suggestion: "Consider negative case: what happens with invalid or missing input?" },
{ pattern: /^should\s+(update|edit|modify|change|patch)/i, category: "negative", suggestion: "Consider negative case: what if the resource doesn't exist?" },
{ pattern: /^should\s+(delete|remove|destroy)/i, category: "negative", suggestion: "Consider negative case: what if the resource is already deleted?" },
{ pattern: /^should\s+(display|show|render|list|get|fetch|load|read)/i, category: "empty", suggestion: "Consider edge case: what if the result is empty or null?" },
{ pattern: /^should\s+(search|filter|sort|find)/i, category: "boundary", suggestion: "Consider boundary: what about empty query, very long query, or special characters?" },
{ pattern: /login|auth|sign.?in|session|token/i, category: "auth", suggestion: "Consider auth: what if the user is not authenticated or session expired?" },
{ pattern: /upload|file|image|attachment|document/i, category: "file", suggestion: "Consider edge case: what about empty files, oversized files, or unsupported formats?" },
{ pattern: /payment|checkout|order|cart|price|billing/i, category: "error", suggestion: "Consider error handling: what if payment fails or times out?" },
{ pattern: /email|notification|sms|message/i, category: "async", suggestion: "Consider async: what if delivery fails or is delayed?" },
{ pattern: /permission|role|access|admin/i, category: "auth", suggestion: "Consider authorization: what if user lacks required permissions?" },
{ pattern: /pagination|page|scroll|infinite|load.?more/i, category: "boundary", suggestion: "Consider boundary: first page, last page, empty page, page beyond max." },
{ pattern: /concurrent|parallel|simultaneous|race/i, category: "concurrency", suggestion: "Consider concurrency: what if two users perform the same action simultaneously?" },
];

// Generic suggestions when no specific patterns match
const GENERIC: string[] = [
"Consider negative case: what happens when input is invalid?",
"Consider edge case: empty or null values.",
"Consider boundary values: min, max, zero, one.",
"Consider error handling: what if the operation fails?",
];

export function suggest(description: string): SuggestionResult {
if (typeof description !== "string" || description.trim().length === 0) {
return { suggestions: GENERIC, score: GENERIC.length };
}

const matched = new Map<string, string>();
for (const p of PATTERNS) {
if (p.pattern.test(description) && !matched.has(p.category)) {
matched.set(p.category, p.suggestion);
}
}

// Always check for missing common aspects
const lower = description.toLowerCase();
if (!lower.includes("error") && !lower.includes("fail") && !lower.includes("invalid") && !matched.has("negative")) {
matched.set("negative", "Consider negative case: what happens with invalid input?");
}
if (!lower.includes("empty") && !lower.includes("null") && !lower.includes("zero") && !lower.includes("none") && !matched.has("empty")) {
matched.set("empty", "Consider edge case: empty, null, or zero values.");
}
if (!lower.includes("boundary") && !lower.includes("limit") && !lower.includes("max") && !lower.includes("min") && !matched.has("boundary")) {
matched.set("boundary", "Consider boundary values for relevant fields.");
}

const suggestions = Array.from(matched.values());
return { suggestions, score: suggestions.length };
}
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,23 @@ export interface TestEachOptions {

/** A single test.each row: [label, input, expected]. */
export type TestEachRow = [string, unknown, boolean];

// Coverage
export interface CoverageMapping {
requirement: string;
matchedTests: string[];
covered: boolean;
}

export interface CoverageResult {
covered: string[];
uncovered: string[];
coveragePercent: number;
mapping: CoverageMapping[];
}

// Suggestions
export interface SuggestionResult {
suggestions: string[];
score: number;
}
Loading