ruby · 5 min read
Your first Ruby gem: test the package, not just the source file
Build a small slug gem with a precise Unicode policy, Minitest checks and an isolated build-install-load test. No publishing account required.
A library can pass every test in its checkout and still fail after installation. The gemspec might omit its only source file. The require path might be wrong. A test that uses require_relative against the working tree would miss both problems.
This tutorial builds a small slug library, then exercises the artifact a consumer would receive. The package stays local. There is no RubyGems account setup or publication step hidden in the example.
Pick a small API with awkward inputs
The public call accepts text and an optional maximum length. It returns a lowercase ASCII slug containing letters, digits and separating hyphens. Blank or unsupported input that leaves no ASCII letters or digits raises ArgumentError.
The policy is deliberately lossy. Accented Latin characters that decompose into a base letter and combining marks can become ASCII; scripts that do not decompose that way are not transliterated. Full-width ABC normalizes to abc, while the example rejects a string consisting only of Japanese characters.
This is not a general international URL policy. Two inputs can produce the same slug, and truncation introduces more collisions. A database-backed application still needs a uniqueness strategy, such as a separate identifier or conflict suffix.
The implementation makes the transformation order visible:
def call(text, max_length: 80) raise ArgumentError, "text must be a valid String" unless text.is_a?(String) && text.valid_encoding? raise ArgumentError, "max_length must be a positive Integer" unless max_length.is_a?(Integer) && max_length.positive? normalized = text.encode(Encoding::UTF_8).unicode_normalize(:nfkd) ascii = normalized.gsub(/\p{Mn}/, "").downcase slug = ascii.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") slug = slug[0, max_length].sub(/-+\z/, "") raise ArgumentError, "no ASCII letters or digits remain" if slug.empty? slugendNormalization happens before removing combining marks. Separators are handled in one replacement, so underscores cannot disappear prematurely and join two words together. Truncation is followed by removal of a trailing hyphen, avoiding an artifact when the length limit lands on a separator.
The String documentation describes normalization forms. Choosing NFKD and then discarding characters is our library policy; normalization alone does not promise language-aware transliteration.
Separate the namespace from the package name
The gem is named oneruby-slug-lab. Its require path is oneruby_slug, and its Ruby namespace is OneRubySlug. These names are related by convention, not by an automatic naming rule.
The project includes a library under lib, a test file under test, a gemspec, README, license and a packaging verification script. The downloadable project archive contains that layout, so the commands below can be run from its root.
Version lives in the library and is read by the gemspec. That gives the installed code and package metadata one source for the example's version number:
Gem::Specification.new do |spec| spec.name = "oneruby-slug-lab" spec.version = OneRubySlug::VERSION spec.authors = ["OneRuby"] spec.summary = "An ASCII slug experiment for a Ruby gem tutorial" spec.homepage = "https://oneruby.dev/" spec.license = "MIT" spec.required_ruby_version = ">= 3.3" spec.files = Dir["lib/**/*.rb"] + ["README.md", "LICENSE"] spec.require_paths = ["lib"] spec.add_development_dependency "minitest", "= 6.0.6"endThe files list is part of the product. It includes library files and the accompanying README and license, rather than every file that happens to exist in the directory. Test helpers and local experiment logs do not need to become runtime payload.
Minitest is a development dependency, pinned to the version used in this lab. The library itself has no runtime gem dependency. Required Ruby version expresses a declared compatibility boundary; it does not establish that every newer Ruby release has been tested.
Test the policy before packaging it
The tests cover ASCII separators, precomposed and decomposed accents, full-width characters, truncation, invalid encoding and an input string that must remain unchanged.
They also check two properties that are easy to confuse. Applying the slugger twice is stable for the tested valid slug, but different source strings can collide. Café and Cafe intentionally produce the same result. That test prevents the documentation from drifting toward an unsupported uniqueness promise.
Rejecting invalid input is part of the API. Silently returning an empty string could move a failure into URL routing, where it is harder to diagnose. The caller can choose a fallback, but the library should not invent one without a stated policy.
Install into a directory that disappears
The packaging check creates a temporary project directory and copies only the files needed to build. It loads the gemspec, builds the gem and installs the resulting local package into a second temporary directory.
Then it starts another Ruby process with only the installed library directory added to its load path. That process requires oneruby_slug, verifies the accented example and prints the package version. The checkout's lib directory is not used for this check.
This catches missing packaged files and a wrong require path. It also exercises code after an actual RubyGems installation rather than merely inspecting the archive. The temporary directories are removed when the check completes.
The subprocess uses explicit Unicode escapes in its tiny command expression so its source is ASCII even under an unusual shell locale. The library and test files remain normal UTF-8 Ruby files. Encoding assumptions deserve attention at process boundaries as well as inside string code.
Run both checks
Unpack the project archive. The recorded versions are Ruby 3.3.2, RubyGems 3.5.9 and Minitest 6.0.6. Install Minitest 6.0.6 into that Ruby environment if it is absent. Run:
ruby test/slug_test.rb --seed 42ruby verify_package.rbThe unit run passed 7 tests and 21 assertions. The second command reported a successful isolated build, installation and load of version 0.1.0. It makes no network request and does not install into your normal gem directory.
Publication is a separate decision
The local name is an example, not a claim that a matching public gem is available or owned. Before releasing a real package, choose its public name, review the included files and license, test the supported Ruby versions, and follow the current RubyGems publishing guidance.
The milestone here is smaller and concrete: a consumer process can load the package that was built. Keep that check alongside the unit tests. Source correctness and packaging correctness fail in different ways.
Found a mistake or tried a different approach?
Send Alex a note ↗