avva: (Default)
[personal profile] avva
Я отношусь с некоторым подозрением к созданию юнит-тестов, покрывающих весь объем исходного кода, в таких языках, как C++ и Java.

В целом мне нравится идея TDD (test-driven development), и мне не раз случалось убеждаться в пользе большого числа быстрых тестов. Но вместе с тем, у меня есть несколько возражений, особенно в случае C++/Java.

Во-первых, если я правильно понимаю историю (тут я повторяю то, что читал где-то), движение TDD зародилось в среде динамических языков. В языках с статическими проверками типизации значительная часть того, что проверяют юнит-тесты в динамических языках, проверяется "бесплатно" компилятором (естественно, это не значит, что динамические языки хуже - у динамической типизации есть свои преимущества). Это, казалось бы, хорошо, но на практике нередко оказывается, что юнит-тесты на C++/Java выходят почти тривиальными, за счет того, что из типичного для динамического языка юнит-теста убирают проверки типизации. В принципе эта проблема решается, нужно просто научиться писать более подходящие для этих языков юнит-тесты, в которых достаточно "мяса".

Во-вторых, слишком много ресурсов и времени уходит на подготовку кода к тому, чтобы его оттестировать (как правило, значительно больше, чем в динамических языках, потому что в них вставлять и втискиваться внутрь существующего кода обычно намного легче). Всевозможные mock'и, разные схемы dependency injection, и прочая мишура, нужная для того, чтобы в конце концов гордо запустить юнит-тест, изолирующий сложный класс, симулирующий с помощью разного рода методов все его заимодействие с внешней средой, и в итоге почти тривиализирующий его поведение, так что тест проходит и все замечательно, да? - нет, потом окажется, что баги мигрировали в область интеракции класса с его окружением.

Я не согласен с довольно распостраненной точкой зрения, гласящей, что вся эта мишура - mock'и, дополнительные интерфейсы/абстрактные классы, позволяющие тестам легко втиснуться в иерархию, и проч. и проч. - что все это полезно само по себе, а не только потому, что помогает тестировать. Что дескать это очень правильно, мы упрощаем и абстрагируем код для удобства проверок, и он от этого только улучшается, поэтому время, на это потраченное, себя окупает. Мне это кажется довольно малооправданным примером wishful thinking. Иногда так действительно бывает; но очень часто бывает и так, что навороченные внутри и снаружи кода бутафорные пристройки на самом деле не проясняют код, не улучшают его, и ни для чего, кроме как запуска тестов, не пригодятся. Я такое видел много раз. Можно провести определенную параллель с design pattern'ами. На них тоже у многих людей едут мозги, и они начинают творить кучи паттернов там, где можно было сделать многое проще, и гордиться самим фактом использования паттернов.

Вместе с тем я все же, несмотря на эти подозрения, принимаю практику массивного юнит-тестирования кода на C++ и Java. Преимущества - обычно - перевешивают вышеупомянутые недостатки. Просто я считаю, что не стоит доходить в этом до фанатизма (и это не пустое заявление - примеры такого фанатизма, по крайней мере по моему мнению, я нередко вижу вокруг себя), и стоит понимать, что цена, которую мы платим за адаптацию нашего кода в C++/Java к максимальному юнит-тестированию, часто бывает весьма высокой, выше, чем кажется на первый взгляд.

Date: 2007-10-09 10:24 pm (UTC)
nine_k: A stream of colors expanding from brain (Default)
From: [personal profile] nine_k
TDD, как его понимаю я, немного про другое. Типизация тут почти ни при чём (хотя её проверку в динамических языках тоже полезно запихать в тесты).
TDD -- это когда я пишу что-то вроде:
assertEquals(fact(1), 1);
assertEquals(fact(3), fact(2)*3);
assertRaises(fact(-1));

и пишу я это *до* того, как придумаю реализацию fact(). Мало ли как можно вычислять факториал :) Но его *свойства* я в тестах хотя бы отчасти зафиксирую (хотя желательно полностью).

Увы, в случае БД дело сильно осложняется.

Date: 2007-10-09 10:29 pm (UTC)
From: [identity profile] mikkim08.livejournal.com
и пишу я это *до* того, как придумаю реализацию fact(). Мало ли как можно вычислять факториал :)

Кстати да. TDD это обманка такая для хакеров. Чтобы как-то заставить их думать о требованиях перед тем как писать код.

Date: 2007-10-09 10:36 pm (UTC)
From: [identity profile] avva.livejournal.com
Все так. Я не слишком разделяю в этой записи вообще-то разные идеи coverage by unit tests и TDD. Можно добиваться очень высокого coverage by unit tests, создавая их параллельно или после самого кода, а не до самого кода, как предлагает TDD. На самом деле на практике так чаще всего и выходит в большинстве компаний/проектов, теоретически стремящихся к TDD, мне кажется.

Просто TDD многими воспринимается как логическое завершение все более увеличивающегося внимания к тестам :) Собственно, в этом взгляде на TDD есть резон.

Date: 2007-10-10 07:56 am (UTC)
From: [identity profile] plakhov.livejournal.com
TDD фиксирует интерфейс метода до того, как мы начали думать о реализации.

В теории это правильно, но для разработки на Java или С++ по-моему очень неудобно как минимум в силу ограниченности их синтаксиса (плохо хотя бы то, что код, банально, не компилируется долгое время; не говоря о более тонких вещах вроде того, что абстракцию high order function можно выразить десятью разными способами, и какой тут окажется правильным, априори совершенно непонятно).

TDD отлично работает там, где задача описывается на DSL, или даже непосредственно данными. Идеальный пример - компиляторы :)

Date: 2007-10-10 02:38 pm (UTC)
From: (Anonymous)
A little correction. The first parameter of assertEquals() is Expected value and the second one is Evaluated value. In your example it's the opposite.

Date: 2007-10-09 10:50 pm (UTC)
From: [identity profile] vodianoj.livejournal.com
Я бы хотел добавить, что поддержка уже существующих юнитестов может отнимать довольно большие ресурсы, особенно, когда проводится "рефакторинги" кода, что может случаться довольно часто в больших проектах. Мне приходилось наблюдать, как в подобных случаях резко отмирали довольно большие куски юнитестов, которые надо было полностью переписывать заново - при том, что сам рефакторинг требовал гораздо меньше усилия.
В одном из проектов в котором я работал старые юнитесты, не выдержав трудностей рефакторинга были полностью убраны из процесса билда. На их месте начали возникать новые где-то через два года.

Date: 2007-10-09 11:15 pm (UTC)
From: [identity profile] ivan-gandhi.livejournal.com
As Josh Kerievsky said, you should test interesting cases.

Date: 2007-10-09 11:23 pm (UTC)
From: [identity profile] gaius-julius.livejournal.com
best words i've ever read

Date: 2007-10-09 11:22 pm (UTC)
From: [identity profile] gaius-julius.livejournal.com
я очень давно (ну, по моим меркам) не писал на ++, но соглашусь в основном.

добавлю лишь, что непомерное разрастание мишуры mock'ов и прочие втискивание юнит-тестов в состояние сферического коня в вакууме сигнализирует о том, что в архитектуре что-то не так.. (ну, это очень близко к тому что "если для ваших юнит-тестов требуются юнит-тетсирование...")

ещё начал замечать, что там где обоснованно выбран ++/Java в качестве языка реализации и используется это всё "на полную", обычно кораздо актуальнее integration testing, нежели хорошее покрытие юнит-тестами, ибо изолировать юниты для тестинга действительно иногда довольно накладно и зачастую не оправдывает целей.

Date: 2007-10-10 07:42 am (UTC)
From: [identity profile] plakhov.livejournal.com
Прикольно, я всю жизнь называл юнит-тестами в том числе и то, что вы называете интеграционными :)

Т.е. мне кажется, что слово unit по-хорошему должно означать не обособленную единицу кода, а обособленную часть требований.

Потому что неправильно (по-моему) тестировать адекватность средства достижения цели, явным образом используя при этом структуру этого же средства.

Date: 2007-10-10 09:35 am (UTC)
From: [identity profile] gaius-julius.livejournal.com
ну, по-хорошему оно много всего могло бы означать, но есть же уже общепринятая терминология... (-:

Date: 2007-10-10 12:54 am (UTC)
From: [identity profile] object.livejournal.com
"Я отношусь с некоторым подозрением к созданию юнит-тестов, покрывающих весь объем исходного кода, в таких языках, как C++ и Java."

Соглашусь с несколько измененным утверждением: "Я отношусь с некоторым подозрением к вере в то, что юнит-тесты, покрывающих весь объем исходного кода, в таких языках, как C++ и Java, дают некоторые гарантии его корректного исполнения".

Или с таким: "Я не считаю разумным тратить дополнительные ресурсы на покрытие юнит-тестами всего объема исходного кода".

С исходным же утверждением я не могу согласиться, ибо постоянно убеждаюсь, что написание юнит-теста к свежеобнаруженной ошибке часто повышает процент покрытия тестами объема исходного кода.

То есть тенденция все же налицо: чем выше процент покрытия, тем ниже вероятность сбоя.

Другое дело, что повышать процент покрытия любой ценой обычно неэффективно. И есть разные рекомендации по тому, как увеличить шансы добраться до необнаруженной ошибки: например, тестировать только публичные методы. Но это уже другой разговор.

Date: 2007-10-10 03:18 am (UTC)
From: [identity profile] max630.livejournal.com
> Я не согласен с довольно распостраненной точкой зрения, гласящей, что вся эта мишура - mock'и, дополнительные интерфейсы/абстрактные классы, позволяющие тестам легко втиснуться в иерархию, и проч. и проч. - что все это полезно само по себе, а не только потому, что помогает тестировать. Что дескать это очень правильно, мы упрощаем и абстрагируем код для удобства проверок, и он от этого только улучшается, поэтому время, на это потраченное, себя окупает.

Любое перетряхивание исходников, независимо от цели, часто полезно само по себе :)

Date: 2007-10-10 04:46 am (UTC)
From: [identity profile] msh.livejournal.com
я не большой любитель unit tests, но при этом я понимаю, что моя нелюбовь по большей части объясняется моей ленью. Я работал в проекте где у меня не было выбора - от всего кода требовалось покрытие unit tests в 100% по функциям и в как минимум в 75% по branches. И надо сказать, дизайн с учетом необходимости такого покрытия имеет свои положительные черты. Например, отсутствие слишком тесного coupling - в тесте ведь второго класса не будет, а будет только stub, так что функциональность приходится четко разделять. Интерфейсы опять же приходится продумывать гораздо тщательнее, заодно уменьшается потребность в всяких mockах

Date: 2007-10-10 05:35 am (UTC)
From: [identity profile] deadkittten.livejournal.com
Может, дело здесь больше в правильном определении юнита?
По-идее, юнит должен быть достаточно самостоятельной частью кода с чёттко выделенным и задокументированным интерфейсом. Тогда место mock'ов занимает "обвязка", которая просто проверяем выходы юнита на заданных входах без вмешательства в его структуру. И такое определение юнитов действительно улучшает общую читаемость...

Date: 2007-10-10 07:31 am (UTC)
From: [identity profile] plakhov.livejournal.com
Так же как на диске кроме файлов присутствует и структура директорий, кроме плоского набора "юнитов" присутствует и та или иная иерархия - packages, библиотеки, проекты, утилиты, namespace'ы и тп.
Поскольку значительная часть ошибок (в Java, я так понимаю, больше половины) - интеграционные, тестировать приходится не только отдельные "юниты", но также и обособленные сущности иерархии. Вот тут и начинается возня с конфигурированием, о которой говорит avva.

Date: 2007-10-10 08:31 am (UTC)
From: [identity profile] deadkittten.livejournal.com
Ну, формально это уже не unit testing, а integration testing. Два весьма отличающихся процесса вообще-то.

Date: 2007-10-10 08:41 am (UTC)
From: [identity profile] plakhov.livejournal.com
Угу, я после комментариев к этому посту уже понял, что слабоват в терминологии.
http://avva.livejournal.com/1818941.html?thread=45227069#t45227069
Если принять такую терминологию, то идея 100% покрытия кода именно юнит-тестами мне кажется крайне нелепой.
Вообще, интеграционные тесты в моем коде встречаются чаще и оказываются на порядок полезнее, хотя бы просто потому что намного чаще ломаются.

Date: 2007-10-10 06:04 am (UTC)
From: [identity profile] crazy-blu.livejournal.com
Я работал в компани, которая оченб хотела сэкономить на QA-отделе и заставляли всех разработчиков писать unit-тесты, а потом еще и проводить самостоятельно интерграционное тестирование. Я тоже считал что "в принципе unit-test это хорошо, но тратить ресурсы... мне лично было бы жалко".

Но сейчас я попал в компанию где тестирования не проводится даже минимального. Где человек выкладывает код, а его работоспособность пусть проверяют тестеры, "иначе за что им деньги платят". Фактически другая половина мира, те, кто не приемлют unit-тестыв как класс (пока, надеюсь).

Не скажу, что появилась куча свободного времени для творчества, отчасти потому что основная работа - править (чужие) баги :(

Date: 2007-10-11 07:59 pm (UTC)
From: [identity profile] romanet.livejournal.com
Не факт, что первой компании не было такого же количества багов. Просто без тестеров их никто не находил.

Date: 2007-10-10 06:17 am (UTC)
From: [identity profile] loislo.livejournal.com
Я вижу в юнит тестах следующие положительные стороны:
1) необходимость их написания заставляет держать мух отдельно, котлеты отдельно, а не замешивать их в один класс;
2) ими гораздо проще тестировать нештатные ситуации, особенно при работе со сторонними библиотеками и именно эти тесты увеличивают покрытие.

Возможно люди которые упирают на стопроцентное покрытие забывают про необходимость smoke-тестов регрессионных тестов и т.д.

Что же касается оверхеда связанного с дополнительными интерфейсами и т.д. очень помогает хороший рефакторинг туль. Но к сожалению для ++ я такого не видел, хотя очень хочется.

Date: 2007-10-10 07:09 am (UTC)
From: [identity profile] plakhov.livejournal.com
Полезные юнит-тесты я привык делить на low level и high level. Это именно самый что ни на есть low и самый что ни на есть high. Юнит-тесты для всех промежуточных уровней я теперь склонен считать вредным излишеством (по крайней мере в С++/Java/C#) именно из-за того, что излишняя конфигурируемость промежуточных уровней требует много ненужной и постоянно протухающей работы. Если деление тестов по уровням интуитивно непонятно, пояснения я когда-то писал тут: http://plakhov.livejournal.com/16309.html

Упомянутые две категории тестов - сильно разные. Проблема встраивания первых - это проблема хорошей инкапсуляции логики, и в этом смысле из наличие, безусловно, улучшают архитектуру.

Проблема встраивания high level юнит-тестов - это частный случай проблемы конфигурируемости. Если у вас сложно сделать high level юнит-тест, значит, вашу систему и вообще сложно сколько-нибудь нетривиально сконфигурировать. Насколько это плохо - очевидно, зависит от системы. Часто ничего плохого в этом нет, но все-таки data-driven системы обычно удобнее разрабатывать коллективно.

Date: 2007-10-10 07:31 am (UTC)
From: [identity profile] insvald.livejournal.com
Я, наверно, ретроград, но идея о том, что для улучшения качества кода надо писать что-то еще мне глубоко антипатична.

Date: 2007-10-10 07:47 am (UTC)
From: [identity profile] plakhov.livejournal.com
А идея не в этом состоит.

Был код Х. Мы добавили к нему тесты и получили пару (X, tests). Затем (или сразу в процессе добавления) мы провели рефакторинг пары (Х, tests) и пришли к состоянию (X', tests'). После этой операции, согласно сторонникам ТДД, мы увидим, что Х' оказался лучше и красивее, чем Х, даже если отбросить тесты.

Не то чтобы я с этим до конца согласен, но разумные и работающие способы использования этой идеи действительно существуют.

Date: 2007-10-10 10:05 pm (UTC)
From: (Anonymous)
довелось как-то видеть одну статью
засланную на конференцию из России
где автор использовал теорию категорий для слздания теорий тестов и рисовал много таких диаграм
было несколько забавно

Date: 2007-10-11 08:23 am (UTC)
From: [identity profile] plakhov.livejournal.com
А какой-то содержательный смысл в этой "теории тестов" в итоге был?
Я не очень понимаю, как на таком уровне абстракции отразить методики тестирования, сколько-нибудь приближенные к реальности. Тестирование ведь довольно мало напоминает, скажем, формальное доказательство свойств программы.

Date: 2007-10-10 09:48 am (UTC)
From: [identity profile] cousin-it.livejournal.com
Ощущение, что русский язык Вам жмет. Мне тоже, когда я пишу о программировании =)

Date: 2007-10-10 12:04 pm (UTC)
recoder: (Default)
From: [personal profile] recoder
А Design by Contract и JML для Java - лучше?

Date: 2007-10-10 12:55 pm (UTC)
From: [identity profile] egorfine.livejournal.com
От unit test'ов (и TDD, по вкусу) есть еще один немаловажный плюс, о котором коллеги часто забывают. Это психологический момент. Написание unit test'ов дает определенный настрой, а их поддержка и регулярный запуск в системе - дают некую платформу уверенности в своем коде. Причем не абстрактую (я уверен в своем коде), а доказательную (я уверен в своем коде, потому что). Более того, привыкнув к этому ощущению, что под тобой есть test coverage и он подставит плечо, не даст тебе закомитить код качества хуже чем - потом от него трудно отказаться. Этот настрой, на самом деле, очень сильно влияет на код, который пишет программист. Я в своих коллективах новых людей всегда принудительно сажаю за юнит тесты, и за шесть лет только пара человек остались недовольны; остальные распробовали и не думаю что откажутся от этой практики сейчас или в будущем.

А из голой идеи unit тестирования вырастают замечательные практики в коллективе.

Например, это требование коммитить код только вместе с unit test'ами. Тогда репозиторий становится чище и стабильнее. Или, скажем, ночная сборка и автоматический запуск всех юнитов и acceptance'ов - это как пульс в коллективе.

Что касается mock'ов, то тут нужна гармония. Я не думаю что от mockup классов код становится лучше. Я полностью согласен с вами, что такие mock'и приводят обычно к тривилизации теста и его бесмысленности. Кроме того, в таком тесте потом, спустя полгода, разобраться нереально, а поддерживать - pain in the ass. Здесь я сторонник довольно больших unit test'ов, где проверяется не один конкретный класс, а целая система.


Date: 2007-10-10 01:09 pm (UTC)
From: [identity profile] msh.livejournal.com
их поддержка и регулярный запуск в системе - дают некую платформу уверенности в своем коде

Да, именно это и есть самый большой недостаток юнит тестов - ложное чувство уверенности и удобная, но совершенно неверная, метрика качества

Date: 2007-10-10 01:41 pm (UTC)
From: [identity profile] egorfine.livejournal.com
Мозги никто не отменял.

Date: 2007-10-14 06:27 pm (UTC)

Date: 2007-10-10 01:35 pm (UTC)
From: [identity profile] kingoleg.livejournal.com
Все хорошо в меру

И так бывает...

Date: 2007-10-10 02:22 pm (UTC)
From: [identity profile] dembel.livejournal.com
У нас был случай: код работал и вдруг перестал. Стал разбираться - начальник поменял код что бы юнит-тест проходил. Спрашивается: что для чего?

Date: 2007-10-10 06:11 pm (UTC)
From: [identity profile] shigin.livejournal.com
Предположим есть проект с достаточно ощутимым количеством case test'ов (или как их там --- когда используется готовый продукт и смотрим что получится) и почти нет unittest'ов.

На Ваш взгляд такой подход имеет право на существование или нет?

Date: 2007-10-10 07:16 pm (UTC)
From: [identity profile] avva.livejournal.com
Очень зависит от того, насколько сложность кода горизонтальная vs. вертикальная (т.е. насколько сложны иерархии) и насколько компетентны программисты. В принципе я могу себе представить проекты, для которых такой подход очень хорош, но на практике, начиная новый проект, я теперь предпочту ошибиться в сторону тестов и буду планировать юнит-тесты (без фанатизма) даже если я подозреваю, что он может оказаться одним из таких проектов.

related reading

Date: 2007-10-11 05:30 am (UTC)
From: [identity profile] novice.livejournal.com
i'm sure you've seen it, but it could be useful in context:

http://beust.com/weblog/archives/000462.html
http://beust.com/weblog/archives/000463.html

especially coming from a testing person, but always pragmatic!

Date: 2007-10-11 10:50 am (UTC)
From: [identity profile] yury-rlx.livejournal.com
У меня такой взгляд на unit-тестирование и TDD.
Основная проблемма с unit-тестированием - временные затраты на их изготовление (тестов), на их поддержку и, в особенности, на их изменение при рефакторинге. Добавить к этому также то, что использование классов и шаблонов проектирования (design patterns) многим не нравится из-за "размазывания" логики по тысяче классов и файлов. Рассуждения всегда стопорятся на констатации диалектического единства и борьбы противоположностей (больше тестов - низкая производительность, меньше тестов - хуже качество). В этот момент у всех срабатывает предохранитель - можно авторитетно заявить о необходимости иметь чувство меры и загадочно удалиться из спора. Мало кому хочется копать дальше. Гораздо проще заявить, что надо тестировать, но не слишком много.
А никаких конкретных критериев избыточности тестов (или избыточности тестирования в целом) не приводить.
В нашей голове любой специфический алгоритм может быть сколько угодно сложной и уникальной идеей. Но стоит попробовать переформулировать его, чтобы изложить на бумаге так, чтобы все поняли - исходные понятия и сущности, в которых разум оперировал, обязательно вдруг трансформируются в набор из стандартных понятий. Каждое из этих стандартных понятий будет каким-то универсальным алгоритмом, оператором и т.д.. В программировании развитие языков идет, как мне кажется по пути переноса как можно большей части логики в декларативную сферу. Из уникального - в универсальное. В конце концов любая программа в идеале должна выглядеть как маленькая процедурка, в которой сосредоточена вся уникальность алгоритма. Соответственно, закулисами, компилятор и процессор могут в реальности производить сложнейшие вычисления. Но такой дизайн кода не будет казаться "размазанной логикой".

Так должно быть в идеале, и не всегда легко добиться этого на практике. Не могу сказать, что даже C++ идеально подходит для этого. Для универсализации кода зачастую надо либо templates использовать, либо множество дополнительных заплаток приписывать вроде деструкторов, функторов и т.п.. Добавить к этому то, что отлаживать все эти заплатки проблематично, заставить всех разработчиков (в том числе и 3rd party) использовать и декларировать исключения - невозможно физически. Становится понятно почему такой мощный и, казалось бы, легко абстрагируемый язык, тем не менее ведет к монстрообразным программам.

Основной аргумент "за TDD" - подготовка тестов заставляет разработчика не писать код, потребующий значительного рефакторинга в будущем. Тесты до написания кода превращают любой сколь угодно сложный алгоритм в относительно простое взаимодействие модулей с декларативной фунциональностью. Постоянный рефакторинг - это попытка добиться максимального перевода логики из процедурной в декларативную со стремлением выкристаллизовать ядро уникальности. Стандартные же модули однажды протестированные, не должны требовать рефакторинга. На практике многие, хоть и дробят программу на классы, но делают это не по принципу экстракции универсального, а по принципу укорачивания кода процедуры - кстати, я помню, как мне в университете говорили, что больше 40 строк в процедуре быть не должно быть. Или более современно - для минимизации каких-нибудь метрик (например, не больше 4-5 условных операторов в процедуре).
Успешное использование TDD возможно только при определенной организации разработки. Когда проводятся постоянные code review, introspective meetings, pair programming, когда вся компания находится в постоянном состоянии познания, если хотите. В отсутствие всего этого TDD действительно ведет к неоправданным затратам, тупому формализму, дурацким багам и т.д. и т.п.. Это - лекарство как часть общего курса лечения под наблюдением врача, а по-отдельности - всего лишь сильнодействующее токсичное вещество.

Date: 2007-10-16 03:53 pm (UTC)
From: [identity profile] zigmar.livejournal.com
А Гугль требует полного покрытия исходников юнит-тестами? Мне вообще такая практика кажется полезной, но чисто по-человечески, когда объём юнит-тестов начинает сильно превышать объём тестируемого кода, это начитает раздражать. А вообще интересуюсь, так как я как раз в процессе интервью в Гугль, и они несколько раз задавали вопросы связанные с юнит-тестами, и видимо, это "ж-ж-ж" неспроста :)

Date: 2007-10-16 04:08 pm (UTC)
From: [identity profile] avva.livejournal.com
Удачи!

Я не могу, увы, подробно обсуждать, как организована система тестирования внутри Гугля :)

Date: 2007-10-16 05:02 pm (UTC)
From: [identity profile] zigmar.livejournal.com
Спасибо! Самому интересно, что из этого выйдет :)

February 2026

S M T W T F S
1 2 3 4 5 67
8 9 10111213 14
15 16 17 18192021
2223 2425262728

Most Popular Tags

Style Credit

Expand Cut Tags

No cut tags
Page generated Feb. 24th, 2026 11:05 am
Powered by Dreamwidth Studios