· Patrick Brady · Software Engineering · 7 min read
Building a Music Theory Library with Test-Driven Development
How I used TDD to build @patrady/chord-js, a TypeScript library for chord recognition, and discovered a better API along the way.
The Journey to Chord-JS
I recently built @patrady/chord-js, a TypeScript library that identifies musical chords, notes, and key signatures on an 88-key piano. What made this project special wasn’t just what I built, but how I built it: using Test-Driven Development (TDD) from the very first line of code.
The result? An intuitive API, excellent test coverage, and one of the most enjoyable development experiences I’ve had. Let me share what I learned.
What is Test-Driven Development?
If you’re not familiar with TDD, here’s the core idea: you write tests before you write the implementation code.
The TDD cycle follows three simple steps, often called “Red-Green-Refactor”:
- Red - Write a failing test that describes what you want your code to do
- Green - Write just enough code to make the test pass
- Refactor - Clean up the code while keeping tests green
This might seem backwards at first. Why write tests for code that doesn’t exist? But this approach has profound effects on your design process, as I discovered while building chord-js.
The Problem: Recognizing Musical Chords
Before diving into the TDD experience, let me explain what chord-js does. In music theory, a chord is a combination of notes played together. For example:
- C Major chord: C, E, G
- D Minor chord: D, F, A
- G7 chord: G, B, D, F
The goal of chord-js was to take a set of notes as input and identify what chord they form. Simple concept, but with lots of complexity:
- Multiple chord types (major, minor, diminished, augmented, seventh chords, etc.)
- Chord inversions (same notes, different bass note)
- Enharmonic equivalents (C# and Db are the same pitch)
- MIDI value integration
- Frequency calculations
This complexity made it the perfect candidate for TDD.
How TDD Shaped the API Design
Here’s where TDD really shined: writing tests first forced me to think like a user of my library, not just its creator.
Discovery 1: Simplified Interface
My initial instinct was to create a complex API with separate methods for different input types:
// What I almost built (without TDD)
const chord1 = Chord.fromNoteNames(['C', 'E', 'G']);
const chord2 = Chord.fromMidiValues([60, 64, 67]);
const chord3 = Chord.fromNoteObjects([note1, note2, note3]);
const chord4 = Chord.fromString('C E G');But when I started writing tests first, I kept repeating the same patterns. The tests were tedious. That pain was a signal.
I realized I could simplify to a single, flexible method:
// What I actually built (with TDD)
const chord1 = Chord.for('C E G');
const chord2 = Chord.for(['C', 'E', 'G']);
const chord3 = Chord.for([note1, note2, note3]);TDD insight: If your tests feel repetitive or awkward, your API probably is too. The tests guided me toward a more intuitive, unified interface.
Discovery 2: Better Error Handling
Writing tests first meant thinking about edge cases before implementation:
it('should return null for unrecognized chord patterns', () => {
expect(Chord.for('C D E')).toBeNull(); // Not a valid chord
});
it('should handle enharmonic equivalents', () => {
const chord1 = Chord.for('C# E# G#');
const chord2 = Chord.for('Db F Ab');
expect(chord1?.getName()).toBe(chord2?.getName());
});
it('should recognize inverted chords', () => {
const chord = Chord.for('E G C'); // C Major, first inversion
expect(chord?.getName()).toBe('C');
});These tests revealed edge cases I would have missed if I’d written the implementation first. Instead of cryptic errors or unexpected behavior, the library now gracefully handles:
- Invalid note combinations
- Enharmonic note names (C# vs Db)
- Chord inversions
- Out-of-range MIDI values
Discovery 3: Clearer Naming
The test descriptions literally became my API documentation:
describe('Note', () => {
it('should get the note name', () => {
expect(new Note('Eb4').getName()).toBe('Eb');
});
it('should get the scientific name with octave', () => {
expect(new Note('Eb4').getScientificName()).toBe('Eb4');
});
it('should calculate frequency in Hz', () => {
expect(new Note('A4').getFrequency()).toBeCloseTo(440);
});
});Reading these tests out loud revealed which method names felt natural and which felt clunky. getName() is obvious. getScientificName() is descriptive. getFrequency() is clear.
Without TDD, I might have ended up with something like name(), fullName(), and freq() - shorter, but less discoverable.
Discovery 4: Composability
TDD encouraged small, focused methods that compose well:
it('should work with key signatures', () => {
const key = new KeySignatureOfD();
const note = new Note('F#4');
expect(key.isInKey(note)).toBe(true);
expect(key.normalize(note).getName()).toBe('F#');
});
it('should convert MIDI to notes to chords', () => {
const notes = [60, 64, 67].map((midi) => Note.fromMidi(midi));
const chord = Chord.for(notes);
expect(chord?.getName()).toBe('C');
});These tests showed me how different parts of the API would be used together. The result was an API where everything composes naturally - Notes work with Chords, Chords work with KeySignatures, and MIDI values convert smoothly to Notes.
The Coverage Benefit
One of TDD’s best side effects: you end up with excellent test coverage without trying.
Because every feature starts as a test, my coverage looked like this from day one:
- Statements: 100%
- Branches: 98%
- Functions: 100%
- Lines: 100%
This wasn’t because I obsessed over coverage metrics. It was a natural outcome of the TDD process. Every function, every branch, every edge case was written to satisfy a test.
This coverage gave me incredible confidence to refactor. I could reshape entire classes knowing that if I broke something, a test would catch it immediately.
The Development Experience
Here’s what surprised me most: TDD made development faster, not slower.
Yes, writing tests first adds upfront time. But I saved far more time by:
- Catching bugs immediately - No debugging sessions trying to figure out why something broke three commits ago
- Clear next steps - The failing test tells you exactly what to implement next
- Fearless refactoring - Change anything with confidence that tests will catch regressions
- Living documentation - The test suite documents every feature and edge case
The development flow felt like a game: write a failing test, make it pass, celebrate, repeat. There’s something deeply satisfying about watching a test suite go from red to green.
See It In Action
Here’s a quick demo of what chord-js can do:
import { Chord, Note, KeySignatureOfD } from '@patrady/chord-js';
// Recognize a chord from note names
const chord = Chord.for('C E G');
console.log(chord?.getName()); // Output: "C"
// Work with individual notes
const note = new Note('Eb4');
console.log(note.getFrequency()); // Output: 311.12698 Hz
console.log(note.getMidiValue()); // Output: 63
// Handle chord inversions
const inverted = Chord.for('E G C'); // C Major, first inversion
console.log(inverted?.getName()); // Output: "C"
// Convert MIDI to chords
const notes = [60, 64, 67].map((midi) => Note.fromMidi(midi));
const midiChord = Chord.for(notes);
console.log(midiChord?.getName()); // Output: "C"
// Work with key signatures
const key = new KeySignatureOfD();
console.log(key.getNotes()); // Output: D, E, F#, G, A, B, C#, D
console.log(key.isInKey(new Note('F#'))); // Output: trueThe library supports 18+ chord types including major, minor, augmented, diminished, seventh chords, and more complex variations.
Lessons Learned
If you’re considering TDD for your next project, here’s what I’d recommend:
Start with TDD-friendly projects
TDD works best when:
- Requirements are clear (I knew what chords should be recognized)
- The domain is well-understood (music theory is well-defined)
- The code has minimal external dependencies
It’s harder to apply strict TDD to:
- Exploratory prototypes where you’re still figuring out what to build
- UI/UX work where visual design drives decisions
- Code with lots of external dependencies (databases, APIs, file systems)
Write the test you wish you could write
Don’t let implementation details leak into your tests. Write the test that describes the behavior you want, even if you’re not sure how to implement it yet. The test will guide you toward a clean implementation.
Test behavior, not implementation
Bad test:
it('should call parseNotes() and then identifyChordType()', () => {
// Testing implementation details
});Good test:
it('should recognize C Major chord from note names', () => {
expect(Chord.for('C E G')?.getName()).toBe('C');
});Embrace the red-green-refactor cycle
Don’t skip the refactor step. Once your test passes, take time to clean up the code. Remove duplication, improve names, simplify logic. The tests give you safety to make bold changes.
The Results
After a few weeks of development, @patrady/chord-js was ready for publication:
- Clean, intuitive API
- 100% test coverage
- Zero runtime bugs found so far (tests caught everything)
- Easy to extend with new chord types
- Comprehensive documentation generated from tests
The project is open source on GitHub, so you can see the full test suite and implementation.
Final Thoughts
TDD fundamentally changed how I think about writing code. Instead of:
- Write implementation
- Manually test it
- Write tests to lock in behavior
- Debug when something breaks
I now do:
- Write test describing desired behavior
- Implement just enough to pass
- Refactor with confidence
- Repeat
The result is code that’s more robust, more maintainable, and honestly, more fun to write.
If you’re building a library, especially one with clear requirements and well-defined behavior, I can’t recommend TDD enough. The upfront investment in tests pays dividends in code quality, development speed, and peace of mind.
Try it on your next project. You might be surprised how much better the experience is.
Want to try chord-js yourself? Install it with:
npm install @patrady/chord-jsCheck out the GitHub repository for full documentation and more examples. And if you have questions about TDD or chord-js, feel free to reach out!