← Nesim Apps

Rewriting for Kotlin Multiplatform Without Changing Behavior: Old Code as a Test Oracle

By Nesim Razon, independent Android developer · 2026-11-16

#Dev notes#Android

I moved two small RSS reader apps and a space app to Kotlin Multiplatform so they could also run on iOS. That meant rewriting code that leaned on Android and Java classes. I did not want a single existing user to notice a difference, so I kept the old implementation in the test sources and made it argue with the new one. Here is how that went.

What had to be rewritten

Common Kotlin code cannot call Android or Java classes, and iOS has none of them. The RSS parser used XmlPullParser, the text cleanup used HtmlCompat, the dates went through SimpleDateFormat, and the space app shuffled its daily cards with java.util.Random. All four had to become plain Kotlin that compiles for both platforms.

A rewrite like this is the classic place to introduce quiet changes: a title that loses an accent, a date that shifts by an hour, a list in a different order. None of these crash anything, so nobody notices until a user writes a review.

Keep the old code as an oracle

My rule was simple: do not delete the old implementation, move it. In TurkSail the previous parser went, unchanged, into a file in the Android test sources called LegacyRss. It is no longer shipped, but it still compiles and still produces the answers I trust, because real users have been looking at its output for years.

The new parser lives in the shared module. The test feeds the same XML to both and asks whether the results are equal.

The parity test

The test parses a feed with the new and the old parser and first checks that both return the same number of articles. Then it walks through them one by one and compares six things field by field: title, link, date, cover image, summary and the cleaned HTML. A failure message names the feed, the article index and the field, so a difference is easy to locate.

It needs Android classes for the old side, so it runs under Robolectric in the app module. By default it uses sample feeds bundled with the project. If I start it with -Drss.live=true, it also downloads the live feeds of the real sites and compares those. That system property has to be forwarded to the test JVM in the module's test configuration, otherwise the switch silently does nothing.

Traps the oracle found

The feeds contain HTML that is itself XML-escaped, so entities are decoded twice: once by the XML pass and once more by the HTML pass. A feed that says an escaped ampersand followed by uuml ends up as the letter u with an umlaut only after both passes. A shortcut that decodes once gives the wrong text.

HtmlCompat knew the named HTML entities, and common code does not. I wrote a table of the 96 Latin-1 named entities, plus the handful of others the feeds use, and handle numeric entities, decimal and hexadecimal, including characters outside the basic range. Unknown entities are left as they are, which is what the old code did.

Dates are RFC 822 with an offset, for example a weekday, a day, a month name, a time and a plus or minus four-digit zone. SimpleDateFormat is gone, so I parse them with a regular expression, build the offset by hand and convert with kotlinx-datetime. Unit tests pin one concrete timestamp both with an offset and with GMT, and check that garbage returns null, as before.

A random generator that must not change

The space app shows a card of the day. The order comes from shuffling the card list with java.util.Random seeded with 2026, and the card index is derived from the date. If the shuffle changed, every existing user would suddenly see a different card on the same day.

So I wrote a small class that reproduces java.util.Random exactly: the 48-bit linear congruential generator, the way nextInt picks a value below a bound (including the retry loop and the power-of-two shortcut), and the same algorithm that Collections.shuffle uses. The oracle here is the real thing: a test shuffles the list with java.util.Random(2026) on the JVM and checks that the ids come out in the same order as with my class.

Small things that mattered

Android keeps the downloaded feed in a cache file named after a hash of the address. I kept the file names identical, so after the update the app still finds the old file and the feed opens offline right away. Nothing in the parity test would have caught a changed file name, which is why I list it here.

The live comparison covered 7 categories and 72 articles for TurkSail and 20 articles for MedyaFobi, with zero differences.

What this does not prove

A parity test only covers the inputs you feed it. Sample feeds and one live snapshot say nothing about an article published next week with an entity or a date format I have never seen. Live feeds also change, so a green run today is a statement about today.

It also shows equality, not correctness: if the old code had a bug, the new code now has the same bug on purpose. I accepted that because the goal was to keep behavior, and fixes are a separate, visible change. For the random generator the same limit applies: it matches for the list sizes I tested, and I rely on the algorithm being deterministic.

Frequently asked questions

Why keep the old code in the tests instead of deleting it?

Because it is the most trusted description of the behavior. Comparing the new code against it field by field is faster and more reliable than writing expected values by hand.

Does the parity test need internet access?

Not by default. It uses bundled sample feeds. The live comparison runs only when the test is started with -Drss.live=true.

Why reimplement java.util.Random instead of using a different shuffle?

A different shuffle would change the daily card for every existing user. Reproducing the exact generator keeps the sequence unchanged.

Can a passing parity test guarantee no regressions?

No. It only covers the inputs it sees, and live data changes, so it lowers the risk but does not remove it.

More from the blog