@rbxts/specium 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ <div align="center">
2
+ <h1>Specium</h1>
3
+ <p>A simple and flexible <code>testing framework</code> for Roblox</p>
4
+ <a href="https://thecogumeta.github.io/specium/"><strong>View docs</strong></a>
5
+ </div>
6
+
7
+ <!--moonwave-hide-before-this-line-->
8
+
9
+ ## Why Specium?
10
+
11
+ Testing in Roblox usually means scattered `print` statements or heavy dependencies like TestEZ. **Specium** is a lightweight alternative that gives you structured test suites, readable assertions, and detailed output — with no external dependencies.
12
+
13
+ ## Features
14
+
15
+ - **Suites & sub-suites**: Organize tests with `suite` and `describe`.
16
+ - **Expressive assertions**: `expect` with matchers like `toBe`, `toEqual`, `toThrow`, `toContain` and more.
17
+ - **Invertible matchers**: Use `.never` to flip any assertion.
18
+ - **Custom failure messages**: Chain `.withMessage(msg)` for clearer output.
19
+ - **Structured results**: Get a full `SpeciumRunResult` to inspect programmatically.
20
+ - **Auto-discovery**: Use `runTests` to scan folders for `.spec` modules automatically.
package/lib/index.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ interface SpeciumResult {
2
+ success: boolean;
3
+ message: string;
4
+ }
5
+
6
+ interface SpeciumRunResult {
7
+ total: number;
8
+ passed: number;
9
+ failed: number;
10
+ suites: SpeciumSuiteResult[];
11
+ }
12
+
13
+ interface SpeciumSuiteResult {
14
+ name: string;
15
+ tests: SpeciumTestResult[];
16
+ subSuites: SpeciumSuiteResult[];
17
+ }
18
+
19
+ interface SpeciumTestResult {
20
+ name: string;
21
+ success: boolean;
22
+ message: string;
23
+ }
24
+
25
+ interface SpeciumSuiteContext {
26
+ it(name: string, fn: () => SpeciumResult | void): void;
27
+ describe(name: string, fn: (ctx: SpeciumSuiteContext) => void): void;
28
+ }
29
+
30
+ interface SpeciumSuite {
31
+ name: string;
32
+ }
33
+
34
+ interface SpeciumMatchersRaw {
35
+ toBe(expected: unknown): void;
36
+ toEqual(expected: unknown): void;
37
+ toBeA(type: string, legacy?: boolean): void;
38
+ toBeTruthy(): void;
39
+ toBeNil(): void;
40
+ toBeNear(expected: number, epsilon?: number): void;
41
+ toBeGreaterThan(expected: number): void;
42
+ toBeLessThan(expected: number): void;
43
+ toContain(item: unknown): void;
44
+ toHaveLength(expected: number): void;
45
+ toMatch(pattern: string): void;
46
+ toThrow(expected?: unknown, ...args: unknown[]): void;
47
+ withMessage(msg: string): SpeciumMatchersRaw;
48
+ }
49
+
50
+ interface SpeciumMatchers extends SpeciumMatchersRaw {
51
+ never: SpeciumMatchersRaw;
52
+ }
53
+
54
+ interface Specium {
55
+ suite(name: string, fn: (ctx: SpeciumSuiteContext) => void): SpeciumSuite;
56
+ run(suite: SpeciumSuite): [string, SpeciumRunResult];
57
+ runTests(
58
+ testsFolder: Instance | Instance[],
59
+ ): [string, Array<SpeciumRunResult>];
60
+ expect(received: unknown): SpeciumMatchers;
61
+ success(message: string): SpeciumResult;
62
+ error(message: string): SpeciumResult;
63
+ }
64
+
65
+ declare const Specium: Specium;
66
+ export = Specium;
package/lib/init.luau ADDED
@@ -0,0 +1,517 @@
1
+ --[=[
2
+ @class Specium
3
+ A lightweight and flexible testing framework for Roblox.
4
+
5
+ Specium allows you to organize tests into suites and sub-suites,
6
+ run them and get a detailed report of the results.
7
+ ]=]
8
+ local Specium = {}
9
+
10
+ type SpeciumTest = {
11
+ name: string,
12
+ fn: () -> SpeciumResult,
13
+ }
14
+ type SpeciumSuite = {
15
+ name: string,
16
+ tests: { SpeciumTest },
17
+ subSuites: { SpeciumSuite },
18
+ }
19
+ type SpeciumTestResult = {
20
+ name: string,
21
+ success: boolean,
22
+ message: string,
23
+ }
24
+ type SpeciumSuiteResult = {
25
+ name: string,
26
+ tests: { SpeciumTestResult },
27
+ subSuites: { SpeciumSuiteResult },
28
+ }
29
+ --[=[
30
+ @interface SpeciumResult
31
+ @within Specium
32
+ .success boolean -- Whether the test passed or failed
33
+ .message string -- A message describing the result
34
+ ]=]
35
+ export type SpeciumResult = {
36
+ success: boolean,
37
+ message: string,
38
+ }
39
+ --[=[
40
+ @interface SpeciumRunResult
41
+ @within Specium
42
+ .total number -- Total number of tests run
43
+ .passed number -- Number of tests that passed
44
+ .failed number -- Number of tests that failed
45
+ .suites {SpeciumSuiteResult} -- Results for each root suite
46
+ ]=]
47
+ export type SpeciumRunResult = {
48
+ total: number,
49
+ passed: number,
50
+ failed: number,
51
+ suites: { SpeciumSuiteResult },
52
+ }
53
+ --[=[
54
+ @interface SpeciumSuiteContext
55
+ @within Specium
56
+ .it (name: string, fn: () -> SpeciumResult) -> () -- Registers a test case inside the current suite
57
+ .describe (name: string, fn: (SpeciumSuiteContext) -> ()) -> () -- Creates a nested sub-suite
58
+ ]=]
59
+ export type SpeciumSuiteContext = {
60
+ it: (name: string, fn: () -> SpeciumResult) -> (),
61
+ describe: (name: string, fn: (SpeciumSuiteContext) -> ()) -> (),
62
+ }
63
+
64
+ --[=[
65
+ @within Specium
66
+ @param name string -- The name of the suite
67
+ @param fn (SpeciumSuiteContext) -> () -- A function that registers tests and sub-suites
68
+ @return SpeciumSuite -- The constructed suite, ready to be passed to `Specium.run`
69
+
70
+ Creates a new test suite.
71
+
72
+ Example:
73
+ ```lua
74
+ local suite = Specium.suite("Math", function(t)
75
+ t.it("adds numbers", function()
76
+ Specium.expect(1 + 1).toBe(2)
77
+ return Specium.success("ok")
78
+ end)
79
+
80
+ t.describe("subtraction", function(t)
81
+ t.it("subtracts numbers", function()
82
+ Specium.expect(5 - 3).toBe(2)
83
+ return Specium.success("ok")
84
+ end)
85
+ end)
86
+ end)
87
+ ```
88
+ ]=]
89
+ function Specium.suite(name: string, fn: (SpeciumSuiteContext) -> ()): SpeciumSuite
90
+ local suite: SpeciumSuite = {
91
+ name = name,
92
+ tests = {},
93
+ subSuites = {},
94
+ }
95
+
96
+ local function createContext(currentSuite: SpeciumSuite): SpeciumSuiteContext
97
+ local ctx = {}
98
+
99
+ function ctx.it(name: string, fn: () -> SpeciumResult)
100
+ table.insert(currentSuite.tests, {
101
+ name = name,
102
+ fn = fn,
103
+ })
104
+ end
105
+
106
+ function ctx.describe(name: string, fn: (SpeciumSuiteContext) -> ())
107
+ local newSuite: SpeciumSuite = {
108
+ name = name,
109
+ tests = {},
110
+ subSuites = {},
111
+ }
112
+
113
+ table.insert(currentSuite.subSuites, newSuite)
114
+
115
+ fn(createContext(newSuite))
116
+ end
117
+
118
+ return ctx
119
+ end
120
+
121
+ fn(createContext(suite))
122
+
123
+ return suite
124
+ end
125
+
126
+ --[=[
127
+ @within Specium
128
+ @param suite SpeciumSuite -- The suite to run
129
+ @return string -- A formatted output string with a tree-view of results and a summary
130
+ @return SpeciumRunResult -- A structured table with the full results
131
+
132
+ Runs all tests in a suite (including sub-suites).
133
+
134
+ Example:
135
+ ```lua
136
+ local output, results = Specium.run(suite)
137
+ print(output)
138
+ -- > Math
139
+ -- [Pass] adds numbers - ok
140
+ -- > subtraction
141
+ -- [Pass] subtracts numbers - ok
142
+ --
143
+ -- Result:
144
+ -- 2/2 (100%) tests passed
145
+ ```
146
+ ]=]
147
+ function Specium.run(suite: SpeciumSuite): (string, SpeciumRunResult)
148
+ local total = 0
149
+ local passed = 0
150
+
151
+ local output = "\n"
152
+
153
+ local results: SpeciumRunResult = {
154
+ total = 0,
155
+ passed = 0,
156
+ failed = 0,
157
+ suites = {},
158
+ }
159
+
160
+ local function runSuite(currentSuite: SpeciumSuite, depth: number): SpeciumSuiteResult
161
+ depth += 1
162
+ local indent = string.rep(" ", depth)
163
+
164
+ local suiteResult: SpeciumSuiteResult = {
165
+ name = currentSuite.name,
166
+ tests = {},
167
+ subSuites = {},
168
+ }
169
+
170
+ output ..= `{string.rep(" ", depth - 1)}> {currentSuite.name}\n`
171
+
172
+ for _, test in currentSuite.tests do
173
+ total += 1
174
+
175
+ local ok, result = pcall(test.fn)
176
+
177
+ if not ok then
178
+ result = Specium.error(tostring(result))
179
+ end
180
+ if not result then
181
+ result = {
182
+ message = ok and "Pass" or "Failure",
183
+ success = ok,
184
+ }
185
+ end
186
+
187
+ passed += result.success and 1 or 0
188
+ output ..= `{indent}[{result.success and "Pass" or "Failure"}] {test.name} - {result.message}\n`
189
+
190
+ table.insert(suiteResult.tests, {
191
+ name = test.name,
192
+ success = result.success,
193
+ message = result.message,
194
+ })
195
+ end
196
+
197
+ for _, sub in currentSuite.subSuites do
198
+ local subResult = runSuite(sub, depth + 1)
199
+ table.insert(suiteResult.subSuites, subResult)
200
+ end
201
+
202
+ return suiteResult
203
+ end
204
+
205
+ local rootResult = runSuite(suite, 0)
206
+
207
+ results.total = total
208
+ results.passed = passed
209
+ results.failed = total - passed
210
+ results.suites = { rootResult }
211
+
212
+ output ..= "\n\nResult:\n"
213
+ output ..= total > 0 and `{passed}/{total} ({math.round(passed / total * 1000) / 10}%) tests passed` or "no tests found"
214
+
215
+ return output, results
216
+ end
217
+
218
+ --[=[
219
+ @within Specium
220
+ @param testsFolder {Instance} | Instance -- A single folder or a list of folders to scan for `.spec` ModuleScripts.
221
+ @return string -- A concatenated output string of all suite results
222
+ @return {SpeciumRunResult} -- A list of results, one per suite found
223
+
224
+ Scans one or more folders for `.spec` ModuleScripts and runs each one as a suite.
225
+
226
+ Each `.spec` module must return a `SpeciumSuite` created via `Specium.suite`.
227
+ If a module fails to load, a warning is emitted and it is skipped.
228
+
229
+ Example:
230
+ ```lua
231
+ -- Single folder
232
+ local output, results = Specium.runTests(ServerScriptService.Tests)
233
+ print(output)
234
+
235
+ -- Multiple folders
236
+ local output, results = Specium.runTests({
237
+ ServerScriptService.Tests,
238
+ ReplicatedStorage.Shared.Tests,
239
+ })
240
+ print(output)
241
+ ```
242
+ ]=]
243
+ function Specium.runTests(testsFolder: { Instance } | Instance): (string, { SpeciumRunResult })
244
+ if typeof(testsFolder) == "Instance" then
245
+ return Specium.runTests({ testsFolder })
246
+ end
247
+
248
+ local output = ""
249
+ local results: { SpeciumRunResult } = {}
250
+
251
+ for _, testFolder in testsFolder do
252
+ for _, testsScript in testFolder:QueryDescendants("ModuleScript") do
253
+ if not testsScript.Name:match("%.spec$") then
254
+ continue
255
+ end
256
+
257
+ local ok, suite = pcall(require, testsScript)
258
+ if not ok then
259
+ warn(`[Specium] Failed to load {testsScript:GetFullName()}: {suite}`)
260
+ continue
261
+ end
262
+ local o, r = Specium.run(suite)
263
+ output ..= `{o}\n`
264
+ table.insert(results, r)
265
+ end
266
+ end
267
+
268
+ return output, results
269
+ end
270
+
271
+ --[=[
272
+ @within Specium
273
+ @param message string -- The failure message
274
+ @return SpeciumResult -- A result with `success = false`
275
+
276
+ Returns a failed `SpeciumResult` with the given message.
277
+
278
+ Example:
279
+ ```lua
280
+ t.it("always fails", function()
281
+ return Specium.error("this test is not implemented yet")
282
+ end)
283
+ ```
284
+ ]=]
285
+ function Specium.error(message: string): SpeciumResult
286
+ return { success = false, message = message }
287
+ end
288
+
289
+ --[=[
290
+ @within Specium
291
+ @param message string -- The success message
292
+ @return SpeciumResult -- A result with `success = true`
293
+
294
+ Returns a successful `SpeciumResult` with the given message.
295
+
296
+ Example:
297
+ ```lua
298
+ -- Returning Specium.success is optional — if omitted, the test is marked as passed automatically.
299
+ t.it("works", function()
300
+ Specium.expect(1 + 1).toBe(2)
301
+ return Specium.success("all good")
302
+ end)
303
+ ```
304
+ ]=]
305
+ function Specium.success(message: string): SpeciumResult
306
+ return { success = true, message = message }
307
+ end
308
+
309
+ --[=[
310
+ @within Specium
311
+ @param received any -- The value to assert against
312
+ @return SpeciumMatchers -- A table of matcher functions
313
+
314
+ Creates a matcher chain for the given value.
315
+
316
+ Use `.never` to invert any matcher, and `.withMessage(msg)` to prepend
317
+ a custom message to any failure.
318
+
319
+ Available matchers:
320
+ - `toBe(expected)` — strict equality (`==`)
321
+ - `toEqual(expected)` — deep equality (tables compared recursively)
322
+ - `toBeA(type, legacy?)` — checks `typeof` (or `type` if `legacy` is true)
323
+ - `toBeTruthy()` — value is not `nil` and not `false`
324
+ - `toBeNil()` — value is `nil`
325
+ - `toBeNear(expected, epsilon?)` — numeric proximity (default epsilon: `1e-5`)
326
+ - `toBeGreaterThan(expected)` — numeric `>`
327
+ - `toBeLessThan(expected)` — numeric `<`
328
+ - `toContain(item)` — table contains value (deep) or string contains substring
329
+ - `toHaveLength(expected)` — checks `#value`
330
+ - `toMatch(pattern)` — string matches a Lua pattern
331
+ - `toThrow(expected?, ...args)` — function throws when called with `args`
332
+ - `withMessage(msg)` — prepends a custom message to any failure
333
+ - `never` — inverts the next matcher
334
+
335
+ Example:
336
+ ```lua
337
+ -- All examples below pass
338
+ Specium.expect(2 + 2).toBe(4)
339
+ Specium.expect("hello world").toContain("world")
340
+ Specium.expect({1, 2, 3}).toHaveLength(3)
341
+ Specium.expect(math.huge).never.toBeNil()
342
+ Specium.expect(function() error("oops") end).toThrow()
343
+ Specium.expect(42).withMessage("answer should be 42").toBe(42)
344
+ Specium.expect(1 + 1).never.toBe(3)
345
+ ```
346
+ ]=]
347
+ function Specium.expect(received: any)
348
+ local function format(value: any, indent: number?): string
349
+ indent = indent or 0
350
+ assert(indent) -- ignore '?'
351
+ local t = typeof(value)
352
+ if t == "string" then
353
+ return `"{value}"`
354
+ elseif t == "number" or t == "boolean" or value == nil then
355
+ return tostring(value)
356
+ elseif t == "table" then
357
+ local parts = {}
358
+ local innerIndent = indent + 1
359
+ local pad = string.rep(" ", innerIndent)
360
+ local closePad = string.rep(" ", indent)
361
+ for k, v in pairs(value) do
362
+ table.insert(parts, `{pad}[{format(k)}] = {format(v, innerIndent)}`)
363
+ end
364
+ if #parts == 0 then
365
+ return "{}"
366
+ end
367
+ return `\{\n{table.concat(parts, ",\n")}\n{closePad}\}`
368
+ else
369
+ return `<{t}> {tostring(value)}`
370
+ end
371
+ end
372
+
373
+ local function deepEqual(a: any, b: any): boolean
374
+ if a == b then
375
+ return true
376
+ end
377
+ if typeof(a) ~= "table" or typeof(b) ~= "table" then
378
+ return false
379
+ end
380
+ for k, v in pairs(a) do
381
+ if not deepEqual(v, b[k]) then
382
+ return false
383
+ end
384
+ end
385
+ for k in pairs(b) do
386
+ if a[k] == nil then
387
+ return false
388
+ end
389
+ end
390
+ return true
391
+ end
392
+
393
+ local function contains(collection: any, item: any): boolean
394
+ local t = typeof(collection)
395
+ if t == "string" then
396
+ return string.find(collection, item, 1, true) ~= nil
397
+ elseif t == "table" then
398
+ for _, v in pairs(collection) do
399
+ if deepEqual(v, item) then
400
+ return true
401
+ end
402
+ end
403
+ end
404
+ return false
405
+ end
406
+
407
+ local function makeMatchers(invert: boolean)
408
+ local matchers = {}
409
+ local op = invert and " not " or " "
410
+ local customMessage: string? = nil
411
+
412
+ local function check(condition: boolean, message: string)
413
+ if invert then
414
+ condition = not condition
415
+ end
416
+ if not condition then
417
+ local finalMessage = customMessage and `{customMessage}\n{message}` or message
418
+ error(finalMessage, 2)
419
+ end
420
+ end
421
+
422
+ function matchers.withMessage(msg: string)
423
+ customMessage = msg
424
+ return matchers
425
+ end
426
+
427
+ function matchers.toBeA(expected: string, legacy: boolean?)
428
+ local getType = legacy and type or typeof
429
+ local actual = getType(received)
430
+ check(
431
+ actual == expected,
432
+ `Expected type: "{expected}"\nReceived type: "{actual}"\nValue: {format(received)}`
433
+ )
434
+ end
435
+
436
+ function matchers.toBe(expected: any)
437
+ check(received == expected, `Expected (===): {format(expected)}\nReceived: {format(received)}`)
438
+ end
439
+
440
+ function matchers.toEqual(expected: any)
441
+ check(
442
+ deepEqual(received, expected),
443
+ `Expected (deep): {format(expected)}\nReceived: {format(received)}`
444
+ )
445
+ end
446
+
447
+ function matchers.toBeTruthy()
448
+ check(received ~= nil and received ~= false, `Expected value to{op}be truthy\nReceived: {format(received)}`)
449
+ end
450
+
451
+ function matchers.toBeNil()
452
+ check(received == nil, `Expected value to{op}be nil\nReceived: {format(received)}`)
453
+ end
454
+
455
+ function matchers.toBeNear(expected: number, epsilon: number?)
456
+ assert(typeof(received) == "number", "Expected received to be a number")
457
+ epsilon = epsilon or 1e-5
458
+ local diff = math.abs(received - expected)
459
+ check(diff <= epsilon, `Expected: {expected} ± {epsilon}\nReceived: {received}\nDifference: {diff}`)
460
+ end
461
+
462
+ function matchers.toBeGreaterThan(expected: number)
463
+ assert(typeof(received) == "number", "Expected received to be a number")
464
+ check(received > expected, `Expected: > {expected}\nReceived: {received}`)
465
+ end
466
+
467
+ function matchers.toBeLessThan(expected: number)
468
+ assert(typeof(received) == "number", "Expected received to be a number")
469
+ check(received < expected, `Expected: < {expected}\nReceived: {received}`)
470
+ end
471
+
472
+ function matchers.toContain(item: any)
473
+ local t = typeof(received)
474
+ assert(t == "string" or t == "table", "Expected received to be a string or table")
475
+ check(contains(received, item), `Expected{op}to contain: {format(item)}\nIn: {format(received)}`)
476
+ end
477
+
478
+ function matchers.toHaveLength(expected: number)
479
+ local t = typeof(received)
480
+ assert(t == "string" or t == "table", "Expected received to be a string or table")
481
+ local actual = #received
482
+ check(actual == expected, `Expected length: {expected}\nReceived length: {actual}`)
483
+ end
484
+
485
+ function matchers.toMatch(pattern: string)
486
+ assert(typeof(received) == "string", "Expected received to be a string")
487
+ check(
488
+ string.match(received, pattern) ~= nil,
489
+ `Expected string to{op}match pattern: "{pattern}"\nReceived: {format(received)}`
490
+ )
491
+ end
492
+
493
+ function matchers.toThrow(expected: any?, ...)
494
+ assert(typeof(received) == "function", "Expected received to be a function")
495
+ local args = { ... }
496
+ local ok, err = pcall(function()
497
+ return received(table.unpack(args))
498
+ end)
499
+
500
+ check(not ok, `Expected function to{op}throw`)
501
+
502
+ if expected ~= nil and not ok then
503
+ assert(err == expected, `Expected error: {format(expected)}\nReceived error: {format(err)}`)
504
+ end
505
+ end
506
+
507
+ if not invert then
508
+ matchers.never = makeMatchers(true)
509
+ end
510
+
511
+ return matchers
512
+ end
513
+
514
+ return makeMatchers(false)
515
+ end
516
+
517
+ return Specium
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@rbxts/specium",
3
+ "version": "1.0.0",
4
+ "description": "A simple and flexible testing framework for Roblox",
5
+ "main": "lib/init.luau",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib"
9
+ ],
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/thecogumeta/specium.git"
16
+ },
17
+ "keywords": [
18
+ "roblox",
19
+ "roblox-ts",
20
+ "rbxts",
21
+ "testing",
22
+ "luau"
23
+ ],
24
+ "author": "Cogu ( @thecogumeta )",
25
+ "license": "ISC",
26
+ "bugs": {
27
+ "url": "https://github.com/thecogumeta/specium/issues"
28
+ },
29
+ "homepage": "https://github.com/thecogumeta/specium#readme",
30
+ "peerDependencies": {
31
+ "@rbxts/types": "*"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }