Can NFC code be tested without a tag in hand?
Half of it. Everything that turns bytes into meaning tests like ordinary code: the frames you build, the responses you parse, the records you decode. Everything that involves the field, a tag leaving, a controller refusing a length, a session the system ends, only appears on a device.
Put a seam in the code
The testable half is the half that never touches the radio: building a command, parsing an answer, deciding what a status word means, turning an NDEF record into something your app understands. The untestable half is four or five calls, transceive(), connect(), close() and their iOS equivalents.
So put those calls behind one interface and keep everything else on the pure side of it. That single decision is what makes the difference between a project with tests and a project where every change needs a tag, and it costs nothing at the time you write the code.
What tests off the device
Frames and parsers, first: the bytes of a SELECT, the walk through the TLV blocks of a Type 2 tag, the two status bytes at the end of an APDU response, the length field of an NDEF file. All of them are functions from bytes to bytes, and their edge cases, a truncated answer, a length that exceeds the buffer, an unknown record type, are exactly what a unit test is for.
Error handling too. A test can hand your code the exception the platform would raise and check what happens next: a retry that starts from detection rather than resuming, a write that reads back before deciding, an authentication that starts over. The tag lost page lists what those exceptions are.
What only a device shows
The limits of the phone in hand: the maximum length one command can carry, whether extended APDUs are supported, which optional technology classes exist, all of which the controller limits page reads at runtime for the same reason.
And the behaviour of the field: a tag that leaves mid write, a session the system ends, a reader that gives up, the lock screen and screen off rules of card emulation, the dispatch the operating system does before your code runs. None of that is simulated by anything, which is why the tests that matter end with a phone and a tag in a hand.
Recorded exchanges as fixtures
Record real exchanges and replay them. A log of every command and response, which the tracing page explains how to produce, becomes a fixture: the same bytes, replayed into the pure half of your code, reproduce the bug without the tag that caused it.
Keep one fixture per tag family and per failure. And keep the tags themselves, which is a shorter list than it sounds: the test set page makes it.
Frequently asked questions
Can an emulator replace a tag?
No. The field, the timing and the controller limits are the part that fails, and an emulator has no radio to fail with.
What should be unit tested?
Everything from bytes to meaning: frames, parsers, status words, record decoding, and how the code reacts to each platform error.
How do I test error handling without causing errors?
Hand the code the exception the platform would raise, behind the interface that wraps the radio calls, and assert what it does next.
Do I still need tags if I have fixtures?
Yes. Fixtures prove the parsing; only a real tag proves the exchange, the timing and the limits of the phone.